{
  "name": "Telegram Expense Tracker with Google Sheets",
  "description": "Send a Telegram message like 'Coffee 3.50 EUR work' and it appears in your Google Sheet in seconds. AI extracts the expense data, validates it, logs the row, and confirms via Telegram. Includes duplicate protection, default currency fallback, user identification and clear error feedback. Configure credentials, set your spreadsheet ID, and activate.",
  "version": "2.0",
  "type": "starter_template",
  "kairox_workflow": "telegram-expense-tracker-google-sheets",
  "stack": {
    "automation": "n8n",
    "messaging": "Telegram Bot API",
    "ai": "OpenAI API (gpt-4o-mini)",
    "storage": "Google Sheets API"
  },
  "required_credentials": [
    {
      "name": "Telegram Bot Token",
      "type": "api_key",
      "where_to_set": "n8n > Settings > Credentials > New > Telegram API",
      "note": "Create a new bot: open Telegram, message @BotFather, send /newbot, follow the steps, copy the token. The bot is private — only you will use it."
    },
    {
      "name": "OpenAI API Key",
      "type": "api_key",
      "where_to_set": "n8n > Settings > Credentials > New > OpenAI API",
      "note": "Used to extract structured expense data from natural-language messages. gpt-4o-mini is cost-effective — at typical personal use (10–30 messages/day), the monthly cost is under $0.50."
    },
    {
      "name": "Google Sheets OAuth2",
      "type": "oauth2",
      "where_to_set": "n8n > Settings > Credentials > New > Google Sheets OAuth2",
      "note": "Authorise via your Google account. The credential only needs access to the specific sheet you create in setup step 3."
    }
  ],
  "nodes": [
    {
      "id": "1",
      "name": "Telegram — Message Trigger",
      "type": "n8n-nodes-base.telegramTrigger",
      "description": "Listens for every message sent to your Telegram bot. Each message triggers one workflow execution.",
      "config": {
        "updates": ["message"],
        "note": "After activating the workflow, n8n automatically registers the webhook with Telegram using your instance's public URL. Your n8n instance must be publicly accessible (not localhost) for the webhook to work. n8n Cloud handles this automatically. For self-hosted: your instance needs a public HTTPS URL."
      }
    },
    {
      "id": "2",
      "name": "Deduplicate — Skip Already Processed",
      "type": "n8n-nodes-base.code",
      "description": "Prevents duplicate entries when Telegram retries a webhook delivery. Uses n8n's built-in workflow static data to remember the last processed update ID.",
      "config": {
        "language": "javascript",
        "code": "// Telegram retries webhook delivery if n8n doesn't respond quickly enough.\n// This node skips messages we've already processed by tracking the last update_id.\n\nconst staticData = $getWorkflowStaticData('global');\nconst lastProcessedId = staticData.lastUpdateId || 0;\nconst currentUpdateId = $input.item.json.update_id || 0;\n\n// Message already processed — skip silently\nif (currentUpdateId > 0 && currentUpdateId <= lastProcessedId) {\n  return []; // Empty return stops this execution branch\n}\n\n// Mark this update as processed\nif (currentUpdateId > 0) {\n  staticData.lastUpdateId = currentUpdateId;\n}\n\n// Pass the message through unchanged\nreturn [$input.item];",
        "note": "Static data persists across executions for this workflow only. If you need to reset duplicate tracking (e.g. after testing), open this node and run: $getWorkflowStaticData('global').lastUpdateId = 0"
      }
    },
    {
      "id": "3",
      "name": "OpenAI — Extract Expense Data",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "description": "Parses the natural-language Telegram message and returns a structured JSON object with all expense fields.",
      "config": {
        "model": "gpt-4o-mini",
        "temperature": 0.1,
        "response_format": "json_object",
        "system_prompt": "You are an expense extraction assistant. Parse natural-language expense messages and return structured JSON.\n\nDEFAULT CURRENCY: USD\nIMPORTANT: Change DEFAULT CURRENCY to your preferred currency (e.g. EUR, GBP, CAD, AUD) if most of your expenses are not in USD.\n\nEXTRACT THESE FIELDS:\n- amount: the numeric cost (required — positive decimal number, e.g. 3.50, 142, 0.99)\n- currency: three-letter ISO code (EUR, USD, GBP, etc.) — use DEFAULT CURRENCY above if not mentioned in the message\n- category: classify into exactly one of: food, transport, accommodation, software, equipment, marketing, health, utilities, entertainment, other\n- description: 2–5 word clean summary (e.g. 'Coffee at work', 'Taxi to airport', 'AWS invoice')\n- merchant: business or vendor name if clearly stated, otherwise null\n- note: any extra context the user included (e.g. 'client meeting', 'reimbursable', 'personal') — otherwise null\n\nCATEGORY GUIDE:\n- food: coffee, lunch, dinner, groceries, restaurant, bar, cafe, drinks, snacks\n- transport: taxi, uber, train, bus, flight, metro, parking, petrol, fuel, ferry\n- accommodation: hotel, airbnb, hostel, rent, housing\n- software: apps, subscriptions, SaaS, APIs, cloud services, domains, hosting\n- equipment: hardware, devices, tools, office supplies, furniture\n- marketing: ads, design, branding, PR, promotion\n- health: pharmacy, doctor, gym, dentist, medical, therapy\n- utilities: electricity, gas, water, internet, phone, insurance\n- entertainment: cinema, events, books, streaming, games, sport, recreation\n- other: tips, bank fees, gifts, or anything that does not fit above\n\nEDGE CASES:\n- Message is not an expense (e.g. 'hello', 'how do I use this?', '?'): set amount to null\n- Amount is mentioned but unclear or zero: set amount to null\n- Currency is missing: use the DEFAULT CURRENCY specified above\n- Merchant is not clearly stated: set merchant to null\n- No extra context: set note to null\n- Round amounts to 2 decimal places\n\nOUTPUT:\nReturn ONLY a valid JSON object. No explanation, no markdown, no extra text.\nFormat: {\"amount\": 3.50, \"currency\": \"EUR\", \"category\": \"food\", \"description\": \"Coffee at work\", \"merchant\": \"Starbucks\", \"note\": null}",
        "user_message": "{{ $('Telegram - Message Trigger').item.json.message.text }}",
        "note": "To change your default currency: find the line 'DEFAULT CURRENCY: USD' in the system_prompt above and replace USD with your currency code. This affects all messages where currency is not explicitly stated."
      }
    },
    {
      "id": "4",
      "name": "Parse and Validate",
      "type": "n8n-nodes-base.code",
      "description": "Safely parses the AI JSON response, validates the amount, applies default currency, adds a timestamp, and attaches the user's Telegram chat ID for identification.",
      "config": {
        "language": "javascript",
        "code": "// Safe fallback currency — must match the DEFAULT CURRENCY in node 3\nconst DEFAULT_CURRENCY = 'USD'; // Change this to match node 3\n\n// Parse AI response safely\nconst raw = $input.item.json.message?.content ||\n            $input.item.json.choices?.[0]?.message?.content ||\n            '{}';\n\nlet parsed;\ntry {\n  parsed = JSON.parse(raw);\n} catch {\n  parsed = { amount: null };\n}\n\n// Validate amount — must be a positive number\nconst rawAmount = parsed.amount;\nconst amount = rawAmount !== null && rawAmount !== undefined\n  ? parseFloat(rawAmount)\n  : null;\nconst isValid = amount !== null && !isNaN(amount) && isFinite(amount) && amount > 0;\n\n// Apply default currency if the AI returned none or returned the placeholder text\nconst currency = (parsed.currency && parsed.currency !== 'DEFAULT CURRENCY')\n  ? parsed.currency.toUpperCase().trim()\n  : DEFAULT_CURRENCY.toUpperCase();\n\n// Telegram message context\nconst message = $('Telegram - Message Trigger').item.json.message;\nconst chatId = message?.chat?.id?.toString() || null;\nconst originalText = message?.text || null;\n\n// Format date as YYYY-MM-DD for spreadsheet sorting\nconst now = new Date();\nconst date = now.toISOString().split('T')[0];\n\nreturn [{\n  json: {\n    is_valid: isValid,\n    date,\n    amount: isValid ? Math.round(amount * 100) / 100 : null,\n    currency,\n    category: (parsed.category || 'other').toLowerCase().trim(),\n    description: (parsed.description || 'Expense').trim().slice(0, 120),\n    merchant: parsed.merchant ? parsed.merchant.trim().slice(0, 80) : null,\n    note: parsed.note ? parsed.note.trim().slice(0, 200) : null,\n    chat_id: chatId,\n    original_message: originalText\n  }\n}];",
        "note": "If you change DEFAULT CURRENCY in node 3, update the DEFAULT_CURRENCY constant in this node to match."
      }
    },
    {
      "id": "5",
      "name": "Route — Valid Expense or Error",
      "type": "n8n-nodes-base.if",
      "description": "Routes to the logging and confirmation path if the extraction found a valid expense. Routes to the helpful error reply if the message could not be parsed as an expense.",
      "config": {
        "conditions": [
          {
            "field": "{{ $json.is_valid }}",
            "operation": "equals",
            "value": true
          }
        ],
        "true_branch": "6",
        "false_branch": "8",
        "note": "The true branch logs the expense and confirms. The false branch sends a clear, friendly explanation — it does not expose raw error details."
      }
    },
    {
      "id": "6",
      "name": "Google Sheets — Log Expense",
      "type": "n8n-nodes-base.googleSheets",
      "description": "Appends the validated expense as a new row in your tracking spreadsheet. Includes the Telegram chat ID so entries from different users remain distinguishable.",
      "config": {
        "operation": "append",
        "spreadsheet_id": "YOUR_SPREADSHEET_ID",
        "range": "Expenses!A:I",
        "columns": {
          "Date": "{{ $json.date }}",
          "Amount": "{{ $json.amount }}",
          "Currency": "{{ $json.currency }}",
          "Category": "{{ $json.category }}",
          "Description": "{{ $json.description }}",
          "Merchant": "{{ $json.merchant ?? '' }}",
          "Note": "{{ $json.note ?? '' }}",
          "Chat ID": "{{ $json.chat_id ?? '' }}"
        },
        "note": "Replace YOUR_SPREADSHEET_ID with the ID from your Google Sheet URL — it is the long string between /d/ and /edit. Create a sheet tab named 'Expenses' with these column headers in row 1: Date, Amount, Currency, Category, Description, Merchant, Note, Chat ID"
      }
    },
    {
      "id": "7",
      "name": "Telegram — Confirm Entry",
      "type": "n8n-nodes-base.telegram",
      "description": "Sends a confirmation back to the user with a summary of what was logged.",
      "config": {
        "operation": "sendMessage",
        "chat_id": "{{ $('Telegram - Message Trigger').item.json.message.chat.id }}",
        "text": "✓ Logged\n{{ $('Parse and Validate').item.json.description }} — {{ $('Parse and Validate').item.json.amount }} {{ $('Parse and Validate').item.json.currency }}{{ $('Parse and Validate').item.json.category ? ' · ' + $('Parse and Validate').item.json.category : '' }}",
        "note": "The confirmation message is intentionally short. The user is on mobile — one glance should confirm their expense was recorded."
      }
    },
    {
      "id": "8",
      "name": "Telegram — Invalid Message Reply",
      "type": "n8n-nodes-base.telegram",
      "description": "Sends a friendly, non-technical explanation when the message could not be read as an expense. Guides the user to retry with a valid format.",
      "config": {
        "operation": "sendMessage",
        "chat_id": "{{ $('Telegram - Message Trigger').item.json.message.chat.id }}",
        "text": "Hmm, I couldn't read that as an expense.\n\nTry something like:\n• Coffee 3.50 EUR\n• Taxi 15 dollars\n• AWS invoice 45.00\n• Lunch with client 28 GBP\n\nJust include an amount — currency and category are optional.",
        "note": "Keep this message calm and non-technical. The user is likely on their phone and just wants to log something quickly — help them get there."
      }
    }
  ],
  "connections": [
    { "from": "1", "to": "2", "label": "new Telegram message" },
    { "from": "2", "to": "3", "label": "not a duplicate" },
    { "from": "3", "to": "4", "label": "AI response" },
    { "from": "4", "to": "5", "label": "parsed data with validation flag" },
    { "from": "5", "to": "6", "branch": "true", "label": "valid amount found" },
    { "from": "5", "to": "8", "branch": "false", "label": "no valid amount — send guidance" },
    { "from": "6", "to": "7", "label": "row appended — send confirmation" }
  ],
  "setup_checklist": [
    "1. Create a Telegram bot: message @BotFather on Telegram → /newbot → follow prompts → copy the token",
    "2. Add Telegram API credentials in n8n > Settings > Credentials using your bot token",
    "3. Create a new Google Sheet with a tab named 'Expenses'. Add these headers in row 1: Date, Amount, Currency, Category, Description, Merchant, Note, Chat ID",
    "4. Add Google Sheets OAuth2 credentials in n8n > Settings > Credentials and authorise your Google account",
    "5. Add OpenAI API credentials in n8n > Settings > Credentials",
    "6. Open node 6 (Google Sheets) and replace YOUR_SPREADSHEET_ID with the ID from your sheet's URL — it is the long string between /d/ and /edit in the address bar",
    "7. If your expenses are mostly not in USD: update DEFAULT CURRENCY in node 3's system_prompt AND the DEFAULT_CURRENCY constant in node 4's code",
    "8. Activate the workflow. n8n will register the Telegram webhook automatically.",
    "9. Open Telegram, find your bot, and send: 'Coffee 3.50 EUR' — you should receive a confirmation and see a new row in your spreadsheet",
    "10. Send a non-expense message like 'hello' — you should receive the friendly error guidance from node 8"
  ],
  "setup_notes": {
    "spreadsheet_columns": "Date (text YYYY-MM-DD), Amount (number), Currency (text), Category (text), Description (text), Merchant (text, optional), Note (text, optional), Chat ID (text)",
    "default_currency": "Change the DEFAULT CURRENCY in two places: (1) the first line of the system_prompt in node 3, and (2) the DEFAULT_CURRENCY constant at the top of the code in node 4. Both must match.",
    "multiple_users": "Each expense row includes a Chat ID column — this is the Telegram chat identifier that uniquely identifies the person who sent the message. For personal use it is always the same. For shared bots, filter by Chat ID to see expenses per user.",
    "n8n_cloud_vs_selfhosted": "n8n Cloud: the Telegram webhook activates automatically when the workflow is turned on. Self-hosted: ensure your n8n instance has a public HTTPS URL. The Telegram Trigger node will display the webhook URL after activation.",
    "deduplication": "Node 2 stores the last processed Telegram update_id using n8n workflow static data. If Telegram retries a delivery (common on slow connections), the duplicate is silently discarded. This state resets if you delete and recreate the workflow."
  },
  "limitations": [
    "This is a Starter template — configure credentials and spreadsheet ID before activating",
    "Currency detection works best when mentioned explicitly. Ambiguous messages fall back to DEFAULT_CURRENCY.",
    "Does not support editing or deleting rows — each message creates a new row",
    "Free OpenAI tier may rate-limit at high message volumes (20+ messages per minute)",
    "The deduplication in node 2 resets if the workflow is deleted and recreated — not a concern for normal use",
    "AI categorization is approximate — unusual expense types may land in 'other' until the category list is tuned"
  ]
}
