diff --git a/README.md b/README.md index bd0f66a..babeaf4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ This is a collection of useful scripts to help you manage your Actual Budget. +- [Requirements](#requirements) +- [Configuration](#configuration) +- [Installation](#installation) +- Scripts: + - [Loan Interest Calculator](#loan-interest-calculator) + ## Requirements - [Actual Budget](https://actualbudget.org/) @@ -24,8 +30,40 @@ ACTUAL_FILE_PASSWORD="" # optional, if you want to use a different cache directory ACTUAL_CACHE_DIR="./cache" + +# optional, name of the payee for added interest transactions +IMPORTER_INTEREST_PAYEE_NAME="Loan Interest" ``` ## Installation Run `npm install` to install any required dependencies. + +## Scripts + +### Loan Interest Calculator + +This script calculates the interest for a loan account and adds the interest +transactions to Actual Budget. + +For each account that you want to automaitcally calculate interest for, you +need to edit the account notes and add the following tags: + +- `interestRate:0.0X` sets the interest rate to X percent (note: be sure to + enter the rate as a decimal and not a percentage) +- `interestDay:XX` sets the day of the month that the interest is calculated + +As an example, if your loan is at 4.5% interest and you want to insert an +interest transaction on the 28th of the month, set the account note to +`interestRate:0.045 interestDay:28`. + +You can optionally change the payee used for the interest transactions by +setting `IMPORTER_INTEREST_PAYEE_NAME` in the `.env` file. + +To run: + +```console +$ node apply-interest.js +``` + +It is recommended to run this script once per month. diff --git a/apply-interest.js b/apply-interest.js new file mode 100644 index 0000000..9f0292a --- /dev/null +++ b/apply-interest.js @@ -0,0 +1,60 @@ +const api = require('@actual-app/api'); +const { closeBudget, ensurePayee, getAccountBalance, getAccountNote, getLastTransactionDate, openBudget } = require('./utils'); +require("dotenv").config(); + +(async () => { + await openBudget(); + + const payeeId = await ensurePayee(process.env.IMPORTER_INTEREST_PAYEE_NAME || 'Loan Interest'); + + const accounts = await api.getAccounts(); + for (const account of accounts) { + if (account.closed) { + continue; + } + + const note = await getAccountNote(account); + + if (note) { + if (note.indexOf('interestRate:') > -1 && note.indexOf('interestDay:') > -1) { + const interestRate = parseFloat(note.split('interestRate:')[1].split(' ')[0]); + const interestDay = parseInt(note.split('interestDay:')[1].split(' ')[0]); + + const interestTransactionDate = new Date(); + if (interestTransactionDate.getDate() < interestDay) { + interestTransactionDate.setMonth(interestTransactionDate.getMonth() - 1); + } + interestTransactionDate.setDate(interestDay); + interestTransactionDate.setHours(5, 0, 0, 0); + + const cutoff = new Date(interestTransactionDate); + cutoff.setMonth(cutoff.getMonth() - 1); + cutoff.setDate(cutoff.getDate() + 1); + + const lastDate = await getLastTransactionDate(account, cutoff); + if (!lastDate) continue; + const daysPassed = Math.floor((interestTransactionDate - new Date(lastDate)) / 86400000); + + const balance = await getAccountBalance(account, interestTransactionDate); + const compoundedInterest = Math.round(balance * (Math.pow(1 + interestRate / 12, 1) - 1)); + + console.log(`== ${account.name} ==`); + console.log(` -> Balance: ${balance}`); + console.log(` as of ${lastDate}`); + console.log(` -> # days: ${daysPassed}`); + console.log(` -> Interest: ${compoundedInterest}`) + + if (compoundedInterest) { + await api.importTransactions(account.id, [{ + date: interestTransactionDate, + payee: payeeId, + amount: compoundedInterest, + notes: `Interest for 1 month at ${100*interestRate}%`, + }]); + } + } + } + } + + await closeBudget(); +})(); diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..baa3a59 --- /dev/null +++ b/utils.js @@ -0,0 +1,127 @@ +const api = require('@actual-app/api'); +require("dotenv").config(); + +module.exports = { + openBudget: async function () { + const url = process.env.ACTUAL_SERVER_URL || ''; + const password = process.env.ACTUAL_SERVER_PASSWORD || ''; + const file_password = process.env.ACTUAL_FILE_PASSWORD || ''; + const sync_id = process.env.ACTUAL_SYNC_ID || ''; + const cache = process.env.IMPORTER_CACHE_DIR || './cache'; + + if (!url || !password || !sync_id) { + console.error('Required settings for Actual not provided.'); + process.exit(1); + } + + console.log("connect"); + await api.init({ serverURL: url, password: password, dataDir: cache }); + + console.log("open file"); + if (file_password) { + await api.downloadBudget(sync_id, { password: file_password, }); + } else { + await api.downloadBudget(sync_id); + } + }, + + closeBudget: async function () { + console.log("done"); + await api.shutdown(); + }, + + getAccountBalance: async function (account, cutoffDate=new Date()) { + const data = await api.runQuery( + api.q('transactions') + .filter({ + 'account': account.id, + 'date': { $lt: cutoffDate }, + }) + .calculate({ $sum: '$amount' }) + .options({ splits: 'grouped' }) + ); + return data.data; + }, + + getLastTransactionDate: async function (account, cutoffDate=new Date()) { + const data = await api.runQuery( + api.q('transactions') + .filter({ + 'account': account.id, + 'date': { $lt: cutoffDate }, + 'amount': { $gt: 0 }, + }) + .select('date') + .orderBy({ 'date': 'desc' }) + .limit(1) + .options({ splits: 'grouped' }) + ); + if (!data.data.length) { + return undefined; + } + return data.data[0].date; + }, + + ensurePayee: async function (payeeName) { + const payees = await api.getPayees(); + let payeeId = payees.find(p => p.name === payeeName)?.id; + if (!payeeId) { + payeeId = await api.createPayee({ name: payeeName }); + } + if (!payeeId) { + console.error('Failed to create payee:', payeeName); + process.exit(1); + } + }, + + ensureCategory: async function (categoryName) { + const categories = await api.getCategories(); + let categoryId = categories.find(c => c.name === categoryName)?.id; + if (!categoryId) { + categoryId = await api.createCategory({ name: categoryName }); + } + if (!categoryId) { + console.error('Failed to create category:', categoryName); + process.exit(1); + } + }, + + getTagValue: function (note, tag, defaultValue=undefined) { + tag += ':' + const tagIndex = note.indexOf(tag); + if (tagIndex === -1) { + return defaultValue; + } + return note.split(tag)[1].split(/[\s]/)[0] + }, + + getNote: async function (id) { + const notes = await api.runQuery( + api.q('notes') + .filter({ id }) + .select('*') + ); + if (notes.data.length && notes.data[0].note) { + return notes.data[0].note; + } + return undefined; + }, + + getAccountNote: async function (account) { + const notes = await api.runQuery( + api.q('notes') + .filter({ id: `account-${account.id}` }) + .select('*') + ); + if (notes.data.length && notes.data[0].note) { + return notes.data[0].note; + } + return undefined; + }, + + sleep: function (ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }, +};