{
  "id": "lclnPF994zvSrslv",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Indonesian Stock (IDX) Invoice Reader",
  "tags": [],
  "nodes": [
    {
      "id": "fbb15b1c-2856-480e-b324-1bda0624c0a3",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -400,
        352
      ],
      "parameters": {
        "width": 460,
        "height": 724,
        "content": "## \ud83c\uddee\ud83c\udde9 Indonesian Stock (IDX) Invoice Reader\n\nAutomatically extract stock transactions from Indonesian broker trade confirmations sent via Telegram.\n\n### How it works\n1. Send a broker PDF or image to your Telegram bot\n2. The file is downloaded and encoded as base64\n3. OpenRouter (Gemini) extracts all transactions as structured JSON\n4. A single batch confirmation is sent with \u2705 / \u274c buttons\n5. On confirm, the structured data is output \u2014 connect to any destination node\n\n### Setup\n1. Create a bot via BotFather \u2192 add **Telegram account** credential\n2. Get an OpenRouter API key \u2192 add **Header Auth** credential named **OpenRouter API** with value `Authorization: Bearer sk-or-...`\n3. Expose n8n via HTTPS (Cloudflare Tunnel, ngrok, or a public server)\n4. Activate the workflow \u2014 Telegram webhook registers automatically\n\n### Customization\nSwap the **Send Confirmation** output to any destination (HTTP Request, Google Sheets, Airtable, etc.). Change the model in **Build Request** \u2014 default is `google/gemini-2.5-flash-lite`."
      },
      "typeVersion": 1
    },
    {
      "id": "2ce04cd4-03ed-4d90-97f4-c40460b2e9d1",
      "name": "Sticky Note - Message Intake",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        112,
        400
      ],
      "parameters": {
        "color": 7,
        "width": 680,
        "height": 320,
        "content": "## \ud83d\udce8 Message Intake\nReceives all Telegram updates (messages and button callbacks), parses the incoming data, and routes file uploads to the processing branch."
      },
      "typeVersion": 1
    },
    {
      "id": "d200c299-1aca-4de9-b1ac-358629c16d36",
      "name": "Sticky Note - File Processing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        768,
        768
      ],
      "parameters": {
        "color": 7,
        "width": 660,
        "height": 304,
        "content": "## \ud83d\udcc1 File Processing\nDownloads the file from Telegram servers, reads the binary data, and encodes it as base64 ready for the AI request."
      },
      "typeVersion": 1
    },
    {
      "id": "d8110936-d046-41fc-bb9c-9e0c7e13b594",
      "name": "Sticky Note - AI Extraction",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1344,
        416
      ],
      "parameters": {
        "color": 7,
        "width": 660,
        "height": 304,
        "content": "## \ud83e\udd16 AI Extraction\nBuilds the OpenRouter request (PDF or image format), calls Gemini, and parses the JSON array of transactions from the response."
      },
      "typeVersion": 1
    },
    {
      "id": "064977ba-9df1-417d-b95d-b6b1a6bd81f4",
      "name": "Sticky Note - Confirmation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2048,
        768
      ],
      "parameters": {
        "color": 7,
        "width": 660,
        "height": 476,
        "content": "## \u2705 Confirmation\nGroups all extracted transactions into a batch, stores them temporarily in workflow static data, and sends a confirmation message with Save All / Cancel buttons."
      },
      "typeVersion": 1
    },
    {
      "id": "e5b0a373-003c-4cb8-90b4-d58eaf3b66f0",
      "name": "Route Type1",
      "type": "n8n-nodes-base.switch",
      "position": [
        592,
        544
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "callback",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "1a3a40ba-e140-46bd-b339-f26348495875",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.is_callback }}"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "file",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "4dff4173-e945-44ee-a450-4e01bd94eee2",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.has_file }}"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "387a2288-194f-43eb-80a4-461ffe675758",
      "name": "Telegram Trigger",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        176,
        560
      ],
      "parameters": {
        "updates": [
          "message",
          "callback_query"
        ],
        "additionalFields": {}
      },
      "typeVersion": 1.1
    },
    {
      "id": "abdc6eac-5b1b-44f1-8da9-5a78eb56a5fc",
      "name": "Parse Message",
      "type": "n8n-nodes-base.code",
      "position": [
        384,
        560
      ],
      "parameters": {
        "jsCode": "const update = $input.first().json;\n\n// Handle callback_query (button tap)\nif (update.callback_query) {\n  const cq = update.callback_query;\n  return {\n    json: {\n      is_callback: true,\n      has_file: false,\n      command: '',\n      callback_id: cq.id,\n      callback_data: cq.data || '',\n      chat_id: cq.message.chat.id,\n      message_id: cq.message.message_id,\n      username: cq.from.first_name || cq.from.username || 'there'\n    }\n  };\n}\n\n// Handle regular message\nconst msg = update.message;\nconst hasFile = msg.document !== undefined || msg.photo !== undefined;\nconst text = (msg.text || '').trim();\nconst chatId = msg.chat.id;\nconst username = msg.from.first_name || msg.from.username || 'there';\n\nlet command = '';\nlet args = '';\n\nif (!hasFile && text.startsWith('/')) {\n  const parts = text.split(' ');\n  command = parts[0].toLowerCase().replace(/@.*$/, '');\n  args = parts.slice(1).join(' ').trim();\n}\n\nreturn {\n  json: {\n    is_callback: false,\n    has_file: hasFile,\n    command,\n    args,\n    text,\n    chat_id: chatId,\n    username,\n    file_id: msg.document?.file_id || (msg.photo ? msg.photo[msg.photo.length - 1].file_id : null),\n    filename: msg.document?.file_name || 'invoice.pdf',\n    mime_type: msg.document?.mime_type || 'image/jpeg'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "f48db73a-00ea-42f4-80b9-09e6fddbea52",
      "name": "Extract File Info",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        928
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\nreturn {\n  json: {\n    file_id: data.file_id,\n    filename: data.filename,\n    mime_type: data.mime_type,\n    chat_id: data.chat_id,\n    username: data.username\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ed99dc61-6ff0-4d91-92a5-4c2747906d7e",
      "name": "Download File",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1024,
        928
      ],
      "parameters": {
        "fileId": "={{ $json.file_id }}",
        "resource": "file",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "0c36dfa0-859a-4fc6-a5ed-3a9004402752",
      "name": "Prepare File",
      "type": "n8n-nodes-base.code",
      "position": [
        1232,
        928
      ],
      "parameters": {
        "jsCode": "const fileInfo = $('Extract File Info').first().json;\n\n// Read binary from filesystem (binaryDataMode: filesystem)\nconst binaryBuffer = await this.helpers.getBinaryDataBuffer(0, 'data');\nconst base64Data = binaryBuffer.toString('base64');\n\nreturn {\n  json: {\n    file_base64: base64Data,\n    mime_type: fileInfo.mime_type,\n    filename: fileInfo.filename,\n    chat_id: fileInfo.chat_id,\n    username: fileInfo.username,\n    received_at: new Date().toISOString()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "a8e8d7f4-aa6b-4809-9c2c-0b46a3572134",
      "name": "Build Request",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        560
      ],
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst isPdf = d.mime_type === 'application/pdf';\n\nconst fileContent = isPdf\n  ? {\n      type: 'file',\n      file: {\n        filename: d.filename,\n        file_data: `data:${d.mime_type};base64,${d.file_base64}`\n      }\n    }\n  : {\n      type: 'image_url',\n      image_url: { url: `data:${d.mime_type};base64,${d.file_base64}` }\n    };\n\nconst prompt = [\n  'You are a financial document parser for Indonesian stock broker trade confirmations.',\n  '',\n  'Extract ALL transactions. Return ONLY a raw JSON array \u2014 no markdown, no code blocks, no commentary.',\n  '',\n  'Output format (always an array):',\n  '[{\"ticker\":\"BBCA\",\"company_name\":\"Bank Central Asia\",\"type\":\"buy\",\"quantity\":10,\"price\":8950,\"fee\":25000,\"total_amount\":8950000,\"transaction_date\":\"2024-02-15\",\"broker\":\"Mandiri Sekuritas\",\"confidence\":0.95}]',\n  '',\n  'Rules:',\n  '- ticker: uppercase, no .JK suffix',\n  '- company_name: full company name as printed in the document. If not found, use \"unknown\"',\n  '- type: \"buy\" (pembelian/beli) or \"sell\" (penjualan/jual)',\n  '- quantity: LOTS only. 1 lot = 100 shares (lembar).',\n  '  If document has a lot column \u2192 use it directly.',\n  '  If document shows only shares \u2192 divide by 100.',\n  '  Example: \"308 lot\" \u2192 308 | \"30.800 lembar\" \u2192 308. NEVER return share count.',\n  '- price: price per share in IDR',\n  '- fee: broker commission in IDR',\n  '- total_amount: shares \u00d7 price in IDR (before fee)',\n  '- transaction_date: YYYY-MM-DD',\n  '- confidence: 0.0\u20131.0 (below 0.7 if uncertain)',\n  '',\n  `Document: ${d.filename}`\n].join('\\n');\n\nconst payload = {\n  model: 'google/gemini-2.5-flash-lite',\n  temperature: 0,\n  max_tokens: 2048,\n  messages: [{\n    role: 'user',\n    content: [{ type: 'text', text: prompt }, fileContent]\n  }]\n};\n\nif (isPdf) {\n  payload.plugins = [{ id: 'file-parser', pdf: { engine: 'mistral-ocr' } }];\n}\n\nreturn {\n  json: {\n    chat_id: d.chat_id,\n    filename: d.filename,\n    mime_type: d.mime_type,\n    payload\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "9e925f68-de8f-43b0-87b4-2fa26ee6fea3",
      "name": "OpenRouter Extract",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1616,
        560
      ],
      "parameters": {
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "method": "POST",
        "options": {
          "timeout": 30000
        },
        "jsonBody": "={{ $json.payload }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "HTTP-Referer",
              "value": "https://n8n.io"
            },
            {
              "name": "X-Title",
              "value": "IDX Invoice Reader"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0b0040e2-0827-44ad-947e-da4717ddf159",
      "name": "Parse Invoice",
      "type": "n8n-nodes-base.code",
      "position": [
        1824,
        560
      ],
      "parameters": {
        "jsCode": "const resp = $input.first().json;\nconst chatId = $('Build Request').first().json.chat_id;\nconst filename = $('Build Request').first().json.filename;\n\nlet raw = '';\ntry {\n  raw = resp.choices[0].message.content.trim();\n} catch(e) {\n  return [{ json: { error: true, error_message: 'No response from LLM', chat_id: chatId, filename } }];\n}\n\n// Strip markdown code blocks if present\nraw = raw.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '').replace(/\\s*```$/i, '').trim();\n\nlet parsed;\ntry {\n  parsed = JSON.parse(raw);\n} catch(e) {\n  return [{ json: { error: true, error_message: e.message, raw_response: raw, chat_id: chatId, filename } }];\n}\n\n// Normalize to array\nconst transactions = Array.isArray(parsed) ? parsed : [parsed];\n\n// Return one item per transaction so Format Confirmation receives all\nreturn transactions.map(t => ({\n  json: {\n    ticker: (t.ticker || '').toUpperCase().replace(/\\.JK$/i, ''),\n    company_name: t.company_name || 'unknown',\n    type: t.type || 'buy',\n    quantity: parseInt(t.quantity) || 0,\n    price: parseFloat(t.price) || 0,\n    fee: parseFloat(t.fee) || 0,\n    total_amount: parseFloat(t.total_amount) || 0,\n    transaction_date: t.transaction_date || '',\n    broker: t.broker || '',\n    confidence: parseFloat(t.confidence) || 0,\n    chat_id: chatId,\n    filename,\n    total_in_batch: transactions.length\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "8f1b3f27-178c-475f-9878-5b4aaf0a48a1",
      "name": "Invoice Error?",
      "type": "n8n-nodes-base.if",
      "position": [
        2096,
        992
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a6faeb5d-74a6-4560-b020-3d1d50997cec",
              "operator": {
                "type": "boolean",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.error }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "04cd428b-e315-4d3a-91e0-3097e5c956e9",
      "name": "Format Confirmation",
      "type": "n8n-nodes-base.code",
      "position": [
        2320,
        896
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst chatId = items[0].json.chat_id;\nconst filename = items[0].json.filename;\nconst broker = items[0].json.broker || 'Broker';\nconst date = items[0].json.transaction_date || '';\n\n// Store batch in workflow static data (persists between executions)\nconst staticData = $getWorkflowStaticData('global');\nconst batchId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);\nstaticData[`batch_${batchId}`] = items.map(i => i.json);\n\n// Build transaction lines\nlet totalAmount = 0;\nlet totalFee = 0;\nconst lines = items.map((item, idx) => {\n  const t = item.json;\n  const lots = t.quantity;\n  const typeEmoji = t.type === 'buy' ? '\ud83d\udfe2' : '\ud83d\udd34';\n  const amount = t.total_amount || (lots * 100 * t.price);\n  totalAmount += amount;\n  totalFee += t.fee || 0;\n  return `${idx + 1}. ${typeEmoji} *${t.ticker}* (${t.company_name}) ${lots} lots @ Rp ${Number(t.price).toLocaleString('id-ID')} = Rp ${Number(amount).toLocaleString('id-ID')}`;\n});\n\nconst confirmData = `confirm_batch|${batchId}`;\nconst cancelData  = `cancel_batch|${batchId}`;\n\nreturn [{\n  json: {\n    chat_id: chatId,\n    text: `\ud83d\udcc4 *Trade Confirmation \u2014 ${date}*\\n\ud83c\udfe6 ${broker}\\n\ud83d\udcc1 ${filename}\\n\\n${lines.join('\\n')}\\n\\n\ud83d\udcb8 Total: Rp ${Number(totalAmount).toLocaleString('id-ID')}\\n\ud83d\udcb3 Fee: Rp ${Number(totalFee).toLocaleString('id-ID')}\\n\\n*Save all ${items.length} transactions?*`,\n    reply_markup: [\n      { text: `\u2705 Save All (${items.length})`, callback_data: confirmData },\n      { text: '\u274c Cancel',                     callback_data: cancelData  }\n    ]\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b951be47-0a1d-4efc-b396-1906b9383a87",
      "name": "Send Confirmation",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2560,
        896
      ],
      "parameters": {
        "text": "={{ $json.text }}",
        "chatId": "={{ $json.chat_id }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "1a01e12b-4e49-4ccf-abcd-e56bbc892059",
      "name": "Reply Invoice Error",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2336,
        1088
      ],
      "parameters": {
        "text": "=\u274c Failed to read invoice: {{ $json.error_message }}\n\nPlease try again with a clearer PDF or image.",
        "chatId": "={{ $json.chat_id }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "f2d57045-290d-412e-8f4c-fc9a881c07e2",
  "connections": {
    "Route Type1": {
      "main": [
        [],
        [
          {
            "node": "Extract File Info",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare File": {
      "main": [
        [
          {
            "node": "Build Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Request": {
      "main": [
        [
          {
            "node": "OpenRouter Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download File": {
      "main": [
        [
          {
            "node": "Prepare File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Invoice": {
      "main": [
        [
          {
            "node": "Invoice Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Message": {
      "main": [
        [
          {
            "node": "Route Type1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Invoice Error?": {
      "main": [
        [
          {
            "node": "Reply Invoice Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telegram Trigger": {
      "main": [
        [
          {
            "node": "Parse Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract File Info": {
      "main": [
        [
          {
            "node": "Download File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenRouter Extract": {
      "main": [
        [
          {
            "node": "Parse Invoice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Confirmation": {
      "main": [
        [
          {
            "node": "Send Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}