add apply-interest script

This commit is contained in:
Robert Dyer
2024-06-19 11:28:33 -05:00
parent 1e25a806d9
commit 7a15e6d6b6
3 changed files with 225 additions and 0 deletions
+38
View File
@@ -2,6 +2,12 @@
This is a collection of useful scripts to help you manage your Actual Budget. 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 ## Requirements
- [Actual Budget](https://actualbudget.org/) - [Actual Budget](https://actualbudget.org/)
@@ -24,8 +30,40 @@ ACTUAL_FILE_PASSWORD="<file password>"
# optional, if you want to use a different cache directory # optional, if you want to use a different cache directory
ACTUAL_CACHE_DIR="./cache" ACTUAL_CACHE_DIR="./cache"
# optional, name of the payee for added interest transactions
IMPORTER_INTEREST_PAYEE_NAME="Loan Interest"
``` ```
## Installation ## Installation
Run `npm install` to install any required dependencies. 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.
+60
View File
@@ -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();
})();
+127
View File
@@ -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);
});
},
};