add apply-interest script
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user