A Telegram bot to make adding transactions to YNAB easier

Posted on 2022/07/22 by Dirk van der Laarse

We use the popular budgeting tool YNAB, and like to follow their 4 Rulesopen in new window.

They do support Direct Import from Banks, but like many US-based companies, they only focus on the US Marketopen in new window

We also bank with Investec, one of the few local banks offering Programmable Bankingopen in new window, in other words, an API. They also allow you to run snippets of Javascript before and after card transactions.

Let's build a Telegram bot that sends a message in our Family Telegram group chat once a card transaction is made! We can make the message interactive, to allow anyone to:

  • Choose the Payee
  • Categorise the transaction
  • Add a memo/description

The amount and date can be parsed automatically - no more amount typo's to fix at the end of the month when we do reconciliation in YNAB!


Here are some helpful links:


Investec Programmable Banking

The first step is to create a proof-of-concept script.

Create main.js on Investec Programmable banking:


const TransactionResult = {
	Declined: 0,
	Success: 1,
	Reversed: 2
}

// We use a combination of three emojis to identify transactions, and keep their replies linked.
const getRandomEmoji = (cat, sub) => {
    const emojis = {
        'Animals & Nature': {
            'animal-mammal':['ðŸĩ','🐒','ðŸĶ','ðŸĶ§','ðŸķ','🐕','ðŸĶŪ','🐕‍ðŸĶš','ðŸĐ','🐚','ðŸĶŠ','ðŸĶ','ðŸą','🐈','ðŸĶ','ðŸŊ','🐅','🐆','ðŸī','🐎','ðŸĶ„','ðŸĶ“','ðŸĶŒ','ðŸĶŽ','ðŸŪ','🐂','🐃','🐄','🐷','🐖','🐗','ðŸ―','🐏','🐑','🐐','🐊','ðŸŦ','ðŸĶ™','ðŸĶ’','🐘','ðŸĶĢ','ðŸĶ','ðŸĶ›','🐭','🐁','🐀','ðŸđ','🐰','🐇','ðŸŋ','ðŸĶŦ','ðŸĶ”','ðŸĶ‡','ðŸŧ','ðŸŧ‍❄ïļ','ðŸĻ','🐞','ðŸĶĨ','ðŸĶĶ','ðŸĶĻ','ðŸĶ˜','ðŸĶĄ','ðŸū'],
            'animal-bird':['ðŸĶƒ','🐔','🐓','ðŸĢ','ðŸĪ','ðŸĨ','ðŸĶ','🐧','🕊','ðŸĶ…','ðŸĶ†','ðŸĶĒ','ðŸĶ‰','ðŸĶĪ','ðŸŠķ','ðŸĶĐ','ðŸĶš','ðŸĶœ'],
            'animal-amphibian':['ðŸļ'],
            'animal-reptile':['🐊','ðŸĒ','ðŸĶŽ','🐍','ðŸē','🐉','ðŸĶ•','ðŸĶ–'],
            'animal-marine':['ðŸģ','🐋','🐎','ðŸĶ­','🐟','🐠','ðŸĄ','ðŸĶˆ','🐙','🐚'],
            'animal-bug':['🐌','ðŸĶ‹','🐛','🐜','🐝','ðŸŠē','🐞','ðŸĶ—','ðŸŠģ','🕷','ðŸ•ļ','ðŸĶ‚','ðŸĶŸ','🊰','ðŸŠą','ðŸĶ '],
            'plant-flower':['💐','ðŸŒļ','ðŸ’Ū','ðŸĩ','ðŸŒđ','ðŸĨ€','🌚','ðŸŒŧ','🌞','🌷'],
            'plant-other':['ðŸŒą','ðŸŠī','ðŸŒē','ðŸŒģ','ðŸŒī','ðŸŒĩ','ðŸŒū','ðŸŒŋ','☘','🍀','🍁','🍂','🍃',]
        },
        'Food & Drink': {
            'food-fruit':['🍇','🍈','🍉','🍊','🍋','🍌','🍍','ðŸĨ­','🍎','🍏','🍐','🍑','🍒','🍓','ðŸŦ','ðŸĨ','🍅','ðŸŦ’','ðŸĨĨ'],
            'food-vegetable':['ðŸĨ‘','🍆','ðŸĨ”','ðŸĨ•','ðŸŒ―','ðŸŒķ','ðŸŦ‘','ðŸĨ’','ðŸĨŽ','ðŸĨĶ','🧄','🧅','🍄','ðŸĨœ','🌰'],
            'food-prepared':['🍞','ðŸĨ','ðŸĨ–','ðŸŦ“','ðŸĨĻ','ðŸĨŊ','ðŸĨž','🧇','🧀','🍖','🍗','ðŸĨĐ','ðŸĨ“','🍔','🍟','🍕','🌭','ðŸĨŠ','ðŸŒŪ','ðŸŒŊ','ðŸŦ”','ðŸĨ™','🧆','ðŸĨš','ðŸģ','ðŸĨ˜','ðŸē','ðŸŦ•','ðŸĨĢ','ðŸĨ—','ðŸŋ','🧈','🧂','ðŸĨŦ'],
            'food-asian':['ðŸą','🍘','🍙','🍚','🍛','🍜','🍝','🍠','ðŸĒ','ðŸĢ','ðŸĪ','ðŸĨ','ðŸĨŪ','ðŸĄ','ðŸĨŸ','ðŸĨ ','ðŸĨĄ'],
            'food-marine':['ðŸĶ€','ðŸĶž','ðŸĶ','ðŸĶ‘','ðŸĶŠ'],
            'food-sweet':['ðŸĶ','🍧','ðŸĻ','ðŸĐ','🍊','🎂','🍰','🧁','ðŸĨ§','ðŸŦ','🍎','🍭','ðŸŪ','ðŸŊ'],
            'drink':['🍞','ðŸĨ›','☕','ðŸŦ–','ðŸĩ','ðŸķ','ðŸū','🍷','ðŸļ','ðŸđ','🍚','ðŸŧ','ðŸĨ‚','ðŸĨƒ','ðŸĨĪ','🧋','🧃','🧉','🧊'],
            'dishware':['ðŸĨĒ','ðŸ―','ðŸī','ðŸĨ„','🔊','🏚']
        }
    }
    const random = (array) => array[~~(Math.random() * array.length)]

    if(!emojis[cat])
        cat = random(Object.keys(emojis))

    if(!emojis[cat][sub])
        sub = random(Object.keys(emojis[cat]))

    return random(emojis[cat][sub])

}

async function notifyOnTelegram(authorization, event) {
    const telegramURL = `https://api.telegram.org/bot${process.env.telegramBotToken}/sendMessage`;

    const uppercaseCurrency = authorization.currencyCode.toUpperCase();
    const transactionId = getRandomEmoji() + getRandomEmoji() + getRandomEmoji()
    const userText = "[@<Card User Here>](tg://user?id=123456789)"
    let amount = investec.helpers.format.decimal(authorization.centsAmount / 100, 100);
    amount = amount.replace(".", "\\.");

    let messageBody;
    switch(event) {
    case TransactionResult.Declined:
        messageBody = 
            [
                `${userText}, your transaction was declined: `,
                `${uppercaseCurrency} *${amount}* `,
                `to _${authorization.merchant.name}_ `,
                `\n||_\\(${authorization.reference}\\)_||`
            ].join('')
        break;
    case TransactionResult.Success:
        messageBody = 
            [
                `${userText} `,
                `paid ${uppercaseCurrency} *${amount}* `,
                `to _${authorization.merchant.name}_ `,
                `\n||_\\(${authorization.reference}\\)_||`,
                `\n${transactionId}`,
                `\n _Reply to add a Memo/Description_`
            ].join('')
        break;
    case TransactionResult.Reversed:
        messageBody = 
            [
                `${userText}, your transaction `,
                `has been reversed: ${uppercaseCurrency} *${amount}* `,
                `to _${authorization.merchant.name}_ `,
                `\n||_\\(${authorization.reference}\\)_||`
            ].join('')
        break;
    default:
        messageBody = `${userText} Unkown Error`;
    }

    const raw = JSON.stringify({
        "chat_id": process.env.telegramGroupChatID,
        "text": messageBody,
        "parse_mode": "MarkdownV2",
        "disable_notification": false,
        "reply_markup": 
            {
                "inline_keyboard": [
                    [
                        {
                            "text": "Payee",
                            "switch_inline_query_current_chat": `Payee (${transactionId}):`
                        },
                        {
                            "text": "Category",
                            "switch_inline_query_current_chat": `Category (${transactionId}):`
                        }
                    ]
                ]
            }
    });

    const requestOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: raw,
        redirect: 'follow'
    };

    const response = await fetch(telegramURL, requestOptions);
};


// This function runs before a transaction.
const beforeTransaction = async (authorization) => {
    console.log(authorization);
    return true // Authorise the transaction
};

// This function runs after a transaction was successful.
const afterTransaction = async (transaction) => {
  await notifyOnTelegram(transaction, TransactionResult.Success);
  console.log(transaction);
};
// This function runs after a transaction has been declined.
const afterDecline = async (transaction) => {
  await notifyOnTelegram(transaction, TransactionResult.Declined);
  console.log(transaction);
};

// This function runs after a transaction has been reversed.
const afterReversal = async (transaction) => {
  await notifyOnTelegram(transaction, TransactionResult.Reversed);
  console.log(transaction);
};


Create an env.json on Investec Programmable banking:

{
    "telegramGroupChatID": "-12312313",
    "telegramBotToken": "123:ABC-DEF"
}

Deploy the Telegram bot on Fly.io

See the code repository here: github.com/dvdl16/ynab_data_getteropen in new window

Required environment variables
# YNAB
APP_YNAB_API_TOKEN = FGHIJK
APP_YNAB_BUDGET_ID = 8cd98e2b-ecf6-46d7-ac63-b597687213f4
APP_YNAB_ACCOUNT_ID = 224b38b5-d5e6-4d18-97eb-ea09ebae19c4
APP_UPTIME_HEARTBEAT_URL = https://kuma.app.somewhere/api/push/1a2b3c4d5e

# Telegram
APP_TELEGRAM_BOT_TOKEN = 12345:ABCDE
Create the app on Fly.ioopen in new window

In the root folder with main.py execute:

flyctl apps create telegram-bot
Set required secrets

Set the required environment variables as Fly.io secrets

flyctl secrets set -a telegram-bot APP_YNAB_API_TOKEN="[APP_YNAB_API_TOKEN here]"
flyctl secrets set -a telegram-bot APP_YNAB_BUDGET_ID="[APP_YNAB_BUDGET_ID here]"
flyctl secrets set -a telegram-bot APP_YNAB_ACCOUNT_ID="[APP_YNAB_ACCOUNT_ID here]"
flyctl secrets set -a telegram-bot APP_TELEGRAM_BOT_TOKEN="[APP_TELEGRAM_BOT_TOKEN here]"
flyctl secrets set -a telegram-bot APP_UPTIME_HEARTBEAT_URL="[APP_UPTIME_HEARTBEAT_URL here]"
Verify Fly.io configuration file

Then we can create an fly.io configuration file. See fly.toml for more details.

Deploy application

After all this actions you shall be able to deploy your application with next command:

flyctl deploy

This command will automatically build and deploy your docker container with telegram bot, and after that it will launch it in cloud.

Final result

telegram

To Do's

  • Change code quality from POC
  • Automatically parse Payee from message
  • Add ability to add a new Payee
  • Send party gif for all incoming transactions
  • Add deployment pipeline for Programmable Card code on Investec

Contributors: Dirk van der Laarse