From b677570a2a5c273f733b0a426e1fed737de947f7 Mon Sep 17 00:00:00 2001 From: Robert Dyer Date: Wed, 19 Jun 2024 19:28:40 -0500 Subject: [PATCH] add track-investments script --- .gitignore | 1 + README.md | 48 ++++++++++++++++ package-lock.json | 11 +++- package.json | 3 +- track-investments.js | 128 +++++++++++++++++++++++++++++++++++++++++++ utils.js | 11 ++++ 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 track-investments.js diff --git a/.gitignore b/.gitignore index ab5b7a1..4a890dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Actual Budget cache cache/ +simplefin.credentials # Logs logs diff --git a/README.md b/README.md index e2ccb0b..ed66447 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This is a collection of useful scripts to help you manage your Actual Budget. - [Loan Interest Calculator](#loan-interest-calculator) - [Tracking Home Prices (Zillow's Zestimate)](#tracking-home-prices-zillows-zestimate) - [Tracking Car Prices (Kelley Blue Book)](#tracking-car-prices-kelley-blue-book) + - [Tracking Investment Accounts](#tracking-investment-accounts) ## Requirements @@ -157,3 +158,50 @@ $ node kbb.js It is recommended to run this script once per month. Note that you will have to periodically update the mileage in the account note. + +### Tracking Investment Accounts + +**NOTE: This script only works with SimpleFIN accounts.** + +This script tracks the value of an investment account. It adds new +transactions to keep the account balance equal to the latest value. This +requires connecting to SimpleFIN to grab the reported account balance, so +that the script can update the transactions to reflect that balance. + +Note that I have some rules set up on the accounts that the script assumes. +First, all payees are set to the same name "Investment". Second, any money I +add to the account to fund it I set the category to something different. In +my case, since I am funding these with my paycheck I categorize them as +"Paycheck" but the key is to set a category different from the ones the script +will utilize. Then all other transactions are categorized as "Investment". + +There are three tags you can set in the account notes: + +- `calcInvestment` - this is the tag that tells the script to track the + balance. You will want this on each account, and then optionally one of the + following. +- `zeroSmall` - this is a helper tag that will zero out any small transactions + (less than $10). This is useful for accounts that have a lot of small + transactions that you don't want to track. For example, one of my accounts + shows dividends as separate transactions, so I use this tag to ignore + those as my account balance does not actually change when those occur (they + are reinvested). +- `dropPayments` - this is a helper tag that will zero out anything in the + payment column. One of my accounts lists stock purchases as separate + transactions, so I ignore those as my account balance does not actually + change when those occur. But these are typically larger payments and I want + to keep small payments (interest accrued) so I use this tag on that account + instead of `zeroSmall`. + +Note that the code has a function named `shouldDrop` that might need to be +modified. This function lists transactions whose note contains certain +strings that are targeted when using `zeroSmall` and `dropPayments`. You may +need to update this to add additional notes to look for. + +To run: + +```console +$ node track-investments.js +``` + +It is recommended to run this script once per month. diff --git a/package-lock.json b/package-lock.json index 36531c5..4f67bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "@actual-app/api": "^6.8.1", "dotenv": "^16.4.5", - "jsdom": "^24.1.0" + "jsdom": "^24.1.0", + "readline-sync": "^1.4.10" } }, "node_modules/@actual-app/api": { @@ -667,6 +668,14 @@ "node": ">= 6" } }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", diff --git a/package.json b/package.json index 08a465b..9dcf2df 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "dependencies": { "@actual-app/api": "^6.8.1", "dotenv": "^16.4.5", - "jsdom": "^24.1.0" + "jsdom": "^24.1.0", + "readline-sync": "^1.4.10" } } diff --git a/track-investments.js b/track-investments.js new file mode 100644 index 0000000..1650cb6 --- /dev/null +++ b/track-investments.js @@ -0,0 +1,128 @@ +const api = require('@actual-app/api'); +const fs = require('fs'); +const readline = require('readline-sync'); +const { closeBudget, ensureCategory, ensurePayee, getAccountBalance, getAccountNote, getTransactions, openBudget } = require('./utils'); +require("dotenv").config(); + + +const getCredentials = async () => { + const token = readline.question('Enter your SimpleFIN setup token: '); + const url = atob(token.trim()); + + const response = await fetch(url, { method: 'post' }); + const api_url = await response.text(); + + const rest = api_url.split('//', 2)[1]; + const auth = rest.split('@', 1)[0]; + const username = auth.split(':')[0]; + const pw = auth.split(':')[1]; + + const data = `${username}:${pw}`; + fs.writeFileSync('simplefin.credentials', data); + return data; +}; + +const loadCredentials = () => { + try { + return fs.readFileSync('simplefin.credentials', 'utf8'); + } catch (err) { + return undefined; + } +}; + +const getSimplefinBalances = async () => { + let credentials = loadCredentials(); + if (!credentials) { + credentials = await getCredentials(); + } + const username = credentials.split(':')[0]; + const pw = credentials.split(':')[1]; + + const url = `https://beta-bridge.simplefin.org/simplefin/accounts?start-date=${new Date().getTime()}&end-date=${new Date().getTime()}`; + const response = await fetch(url, { + headers: { + 'Authorization': `Basic ${btoa(`${username}:${pw}`)}` + } + }); + const data = await response.json(); + const accounts = data.accounts; + const balances = {}; + accounts.forEach(a => balances[a.name] = parseFloat(a.balance)); + return balances; +}; + +const shouldDrop = (payment) => { + const note = payment.notes; + return note && (note.indexOf('YOU BOUGHT ') > -1 || note == 'Buy Other' || note == 'Sell Other'); +}; + +const zeroTransaction = async (payment) => { + await api.updateTransaction( + payment.id, + { 'amount': 0 } + ); +} + +(async () => { + await openBudget(); + + const payeeId = await ensurePayee(process.env.IMPORTER_INVESTMENT_PAYEE_NAME || 'Investment'); + + const categoryId = await ensureCategory(process.env.IMPORTER_INVESTMENT_CATEGORY_NAME || 'Investment'); + + const simplefinBalances = await getSimplefinBalances(); + + const accounts = await api.getAccounts(); + for (const account of accounts) { + if (account.closed) { + continue; + } + + const note = await getAccountNote(account); + + if (note) { + const data = await getTransactions(account); + + if (note.indexOf('zeroSmall') > -1) { + const payments = data.filter(payment => payment.amount > -10000 && payment.amount < 10000 && payment.amount != 0 && payment.category == categoryId) + for (const payment of payments) { + if (shouldDrop(payment)) { + await zeroTransaction(payment); + } + } + } + + if (note.indexOf('dropPayments') > -1) { + const payments = data.filter(payment => payment.amount < 0) + for (const payment of payments) { + if (shouldDrop(payment)) { + await zeroTransaction(payment); + } + } + } + + if (note.indexOf('calcInvestment') > -1) { + const currentBalance = await getAccountBalance(account); + const simplefinBalance = parseInt(simplefinBalances[account.name] * 100); + const diff = simplefinBalance - currentBalance; + + if (diff != 0) { + console.log('Account:', account.name); + console.log('Simplefin Balance:', simplefinBalance); + console.log('Current Balance:', currentBalance); + console.log('Difference:', diff); + + await api.importTransactions(account.id, [{ + date: new Date(), + payee: payeeId, + amount: diff, + category: categoryId, + notes: `Update investment balance to ${simplefinBalance / 100}`, + }]); + } + } + } + } + + await closeBudget(); +})(); diff --git a/utils.js b/utils.js index baa3a59..5ca3360 100644 --- a/utils.js +++ b/utils.js @@ -43,6 +43,17 @@ module.exports = { return data.data; }, + getTransactions: async function (account) { + const data = await api.runQuery( + api.q('transactions') + .select('*') + .filter({ + 'account': account.id, + }) + ); + return data.data; + }, + getLastTransactionDate: async function (account, cutoffDate=new Date()) { const data = await api.runQuery( api.q('transactions')