6 Commits

Author SHA1 Message Date
rlkollman 2af2b28d84 Updated zestimate to use puppeteer 2026-05-24 08:57:32 -05:00
rlkollman 76c6eefb36 Add install.sh 2026-05-24 08:55:14 -05:00
Robert Dyer 2b355bf057 pin chromium versions 2025-05-29 13:16:15 -05:00
Robert Dyer 6759916323 more specific base version 2025-05-29 13:13:26 -05:00
google-labs-jules[bot] 6124a6d582 feat: Use Node.js Alpine base image in Dockerfile
Updates the Dockerfile to use `node:22-alpine` as the base image,
reducing the overall image size and standardizing on Alpine Linux.

Key changes include:
- Replaced `apt-get` with `apk add --no-cache` for package installation.
- Updated package names to their Alpine equivalents (e.g., `libasound2` to `alsa-lib`).
- Switched from manual Chrome/Chromedriver downloads to installing `chromium`
  and `chromium-chromedriver` from Alpine repositories.
- Pinned `chromium` and `chromium-chromedriver` to version `136.0.7103.113-r0`
  for build consistency.
- Ensured correct ordering for `WORKDIR`, directory creation/permissions,
  and `USER node` commands.
2025-05-29 18:10:09 +00:00
google-labs-jules[bot] 1d50da987f Refactor: Update Dockerfile to Node.js Alpine base
I've switched the base image from node:22 to node:22-alpine to reduce image size.

Key changes include:
- Updated FROM instruction to node:22-alpine.
- Replaced apt-get with apk for package management.
- Updated package names to their Alpine equivalents (e.g., libasound2 to alsa-lib, libgtk-4-1 to gtk+3.0).
- Modified Chrome and Chromedriver installation to use Alpine's `chromium` and `chromium-chromedriver` packages instead of manual downloads.
- Ensured correct user, working directory permissions, and order of operations, particularly for directory creation and ownership before switching to the 'node' user.

Note: The Docker image build process encountered an environmental 'no space left on device' error. Therefore, I could not complete full build verification and subsequent functional testing of the image. The Dockerfile changes are based on best practices for Alpine conversion.
2025-05-22 05:07:00 +00:00
14 changed files with 284 additions and 1569 deletions
-5
View File
@@ -1,7 +1,2 @@
.git/
.idea/
.vscode/
.github/
.gitignore
node_modules/
package-lock.json
-7
View File
@@ -43,12 +43,6 @@ jobs:
# set latest tag for default branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push the Docker image
id: push
uses: docker/build-push-action@v6
@@ -58,4 +52,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
+19 -34
View File
@@ -1,32 +1,25 @@
# Use an official Node.js runtime as a parent image
FROM node:22
FROM node:22.16.0-alpine3.21
RUN apt-get update -qq -y && \
apt-get install -y \
libasound2 \
libatk-bridge2.0-0 \
libgtk-4-1 \
libnss3 \
RUN apk add --no-cache \
alsa-lib \
at-spi2-atk \
gtk+3.0 \
nss \
xdg-utils \
wget && \
wget -q -O chrome-linux64.zip https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.204/linux64/chrome-linux64.zip && \
unzip chrome-linux64.zip && \
rm chrome-linux64.zip && \
mv chrome-linux64 /opt/chrome/ && \
ln -s /opt/chrome/chrome /usr/local/bin/ && \
wget -q -O chromedriver-linux64.zip https://storage.googleapis.com/chrome-for-testing-public/131.0.6778.204/linux64/chromedriver-linux64.zip && \
unzip -j chromedriver-linux64.zip chromedriver-linux64/chromedriver && \
rm chromedriver-linux64.zip && \
mv chromedriver /usr/local/bin/
# Don't run as root
USER node
wget \
unzip \
chromium=136.0.7103.113-r0 \
chromium-chromedriver=136.0.7103.113-r0
# Set the working directory in the container
WORKDIR /usr/src/app
# Create the cache directory
RUN mkdir -p ./cache && chown node:node ./cache
# Create the cache directory and set ownership (as root)
RUN mkdir -p ./cache && chown -R node:node ./cache
# Don't run as root
USER node
# Define environment variables
ENV NODE_ENV=production
@@ -49,9 +42,9 @@ ENV ACTUAL_CACHE_DIR="./cache"
# optional, name of the payee for added interest transactions
ENV INTEREST_PAYEE_NAME="Loan Interest"
# optional, name of the payee for added investment transactions
# optional, name of the payee for added interest transactions
ENV INVESTMENT_PAYEE_NAME="Investment"
# optional, name of the category group for added investment tracking transactions
# optional, name of the cateogry group for added investment tracking transactions
ENV INVESTMENT_CATEGORY_GROUP_NAME="Income"
# optional, name of the category for added investment tracking transactions
ENV INVESTMENT_CATEGORY_NAME="Investment"
@@ -59,27 +52,19 @@ ENV INVESTMENT_CATEGORY_NAME="Investment"
# optional, for logging into SimpleFIN
ENV SIMPLEFIN_CREDENTIALS=""
# optional, name of the payee for Zestimate entries
ENV ZESTIMATE_PAYEE_NAME="Zestimate"
# optional, name of the payee for KBB entries
ENV KBB_PAYEE_NAME="KBB"
# optional, for retrieving Bitcoin Price (these default to Kraken USD)
ENV BITCOIN_PRICE_URL="https://api.kraken.com/0/public/Ticker?pair=xbtusd"
ENV BITCOIN_PRICE_JSON_PATH="result.XXBTZUSD.c[0]"
ENV BITCOIN_PAYEE_NAME="Bitcoin Price Change"
#optional, RentCast API key for fetching property data
ENV RENTCAST_API_KEY=""
ENV RENTCAST_PAYEE_NAME="RentCast"
VOLUME ./cache
# Copy the current directory contents into the container at /usr/src/app
# This should happen after WORKDIR is set and USER is node
COPY --chown=node:node . .
# Install any needed packages specified in package.json
# This should run as the node user
RUN npm install && npm update
# Run the app when the container launches
+2 -61
View File
@@ -47,7 +47,7 @@ ACTUAL_CACHE_DIR="./cache"
# optional, name of the payee for added interest transactions
INTEREST_PAYEE_NAME="Loan Interest"
# optional, name of the payee for added investment transactions
# optional, name of the payee for added interest transactions
INVESTMENT_PAYEE_NAME="Investment"
# optional, name of the category group for added investment tracking transactions
INVESTMENT_CATEGORY_GROUP_NAME="Income"
@@ -72,23 +72,6 @@ RENTCAST_API_KEY="<Rentcast API key>"
RENTCAST_PAYEE_NAME="RentCast"
```
### OIDC Auth Provider Support
When using OIDC, in your Actual Budget server config you must be able to log in
with a password.
Set the following in your **server config** (not in your helpers config!), then
on initial login you must set a password:
```
ACTUAL_OPENID_AUTH_METHOD=openid
ACTUAL_LOGIN_METHOD=openid
ACTUAL_ALLOWED_LOGIN_METHODS=openid,password,header
ACTUAL_OPENID_ENFORCE=false
```
Then configure the helpers for password login, as shown above.
## Installation
Run `npm install` to install any required dependencies.
@@ -185,9 +168,7 @@ interest transaction on the 28th of the month, set the account note to
By default, interest is calculated using the 30/360 method where interest is
computed monthly using 30/360 (or 1/12) of the interest rate. If you need to
compute interest using the ACTUAL/ACTUAL method, set `interest:actual` in the
note. If you need to compute interest daily, set `interest:daily`. For most
student loans, you probably want simple dailiy interest
`interest:daily-simple`.
note. If you need to compute interest daily, set `interest:daily`.
You can optionally change the payee used for the interest transactions by
setting `INTEREST_PAYEE_NAME` in the `.env` file.
@@ -409,43 +390,3 @@ node sync-bitcoin.js
```
It is recommended to run this script once per day or week.
### Tracking Crypto Prices
This script tracks the value of any cryptocurrency. Similar to the Bitcoin
script, it adds new transactions to keep the account balance equal to the
latest value. Values are retrieved from the Kraken API, but the script is
customizable to use other APIs if needed.
To use, set these tags in the account notes:
- `CRYPTO:<symbol>,<symbol>,...` - where each symbol is a cryptocurrency
you want to track, e.g. `CRYPTO:BTC,ETH,DOGE`
- `<symbol>:<amount>[,<krakenPair>]` - where `<symbol>` is the cryptocurrency
symbol and `<amount>` is the amount you own. If using Kraken, specify the Kraken
pair to use for the price. Obtain this by visiting
[https://api.kraken.com/0/public/Ticker?pair=<symbol\>usd](https://api.kraken.com/0/public/Ticker?pair=btcusd)
and locating the key under `result` that matches your symbol.
For example, if you own 0.01 BTC and 1 ETH, your account note would look like:
```
CRYPTO:BTC,ETH
BTC:0.01,XXBTZUSD
ETH:1,XXETHZUSD
```
To use a different API, set the following in your `.env` file: (example using Bitcoin)
```shell
CRYPTO_PRICE_URL_BTC="https://api.kraken.com/0/public/Ticker?pair=xbtgbp"
CRYPTO_PRICE_JSON_PATH_BTC="result.XXBTZGBP.c[0]"
```
To run:
```console
node sync-crypto.js
```
It is recommended to run this script once per day or week. If you are retrieving
BTC prices using this script, DO NOT run the `sync-bitcoin.js` script as well, as it will
set the account balance to the value of your BTC holdings only.
+1 -1
View File
@@ -1 +1 @@
v26.5.2
v25.5.0
+2 -10
View File
@@ -41,15 +41,12 @@ function daysInYear(year) {
const lastDate = await getLastTransactionDate(account, cutoff);
if (!lastDate) continue;
const daysPassed = Math.round(
(interestTransactionDate.setHours(0, 0, 0, 0) - new Date(lastDate).setHours(0, 0, 0, 0)) / 86400000
);
const daysPassed = Math.floor((interestTransactionDate - new Date(lastDate)) / 86400000);
let period = 12;
let numPeriods = 1
switch (kind) {
case 'daily':
case 'daily-simple':
period = daysInYear(interestTransactionDate.getFullYear());
numPeriods = daysPassed;
break;
@@ -61,12 +58,7 @@ function daysInYear(year) {
}
const balance = await getAccountBalance(account, interestTransactionDate);
let compoundedInterest;
if (kind == 'daily-simple')
compoundedInterest = Math.round(balance * (interestRate / 365) * numPeriods);
else
compoundedInterest = Math.round(balance * (Math.pow(1 + interestRate / period, numPeriods) - 1));
const compoundedInterest = Math.round(balance * (Math.pow(1 + interestRate / period, numPeriods) - 1));
interestRate = showPercent(interestRate);
-74
View File
@@ -1,74 +0,0 @@
const api = require('@actual-app/api');
const { closeBudget, ensurePayee, getAccountBalance, getAccountNote, getTagValue, openBudget, showPercent, sleep,
getBudgetMonth, getCategoryGroups, getCategories } = require('./utils');
require("dotenv").config();
(async function () {
await openBudget();
var today = new Date();
var yyyy = today.getFullYear();
var mm = (today.getMonth() + 1).toString().padStart(2, '0');
var budgetMonth = yyyy + '-' + mm;
const budget = await api.getBudgetMonth(budgetMonth);
console.log('');
console.log('~~!!'); // Make it easy to parse for e-mail
console.log('Budget for Month: ' + budgetMonth);
console.log('Total Budget: $' + (budget.totalBudgeted * .01).toFixed(2).padStart(8, ' '));
console.log('Income: $' + (budget.totalIncome * .01).toFixed(2).padStart(8, ' '));
console.log('Spent: $' + (budget.totalSpent * .01).toFixed(2).padStart(8, ' '));
console.log('Balance: $' + (budget.totalBalance * .01).toFixed(2).padStart(8, ' '));
console.log('----------------------------------------------------');
const categoryGroups = await api.getCategoryGroups();
for (const categoryGroup of budget.categoryGroups) {
if (categoryGroup.hidden || categoryGroup.budgeted == 0 || categoryGroup.is_income) {
continue;
}
const categorySpentPercent = ((categoryGroup.spent * .01) / (categoryGroup.budgeted * .01) * -100);
const categoryBalancePercent = ((categoryGroup.balance * .01) / (categoryGroup.budgeted * .01) * 10);
console.log(categoryGroup.name.padEnd(21, ' ') + ' ' + categorySpentPercent.toFixed(1).trim().padStart(7, ' ') + '% spent of $' + (categoryGroup.budgeted * .01).toFixed(2).trim().padStart(8, ' '));
}
console.log('!!~~'); // Make it easy to parse for e-mail
console.log('');
var transactionDate = new Date();
transactionDate.setDate(transactionDate.getDate() - 1);
let txDate = transactionDate.toISOString().substring(0, 10);
console.log('Transactions on ' + txDate);
console.log('DATE ACCOUNT PAYEE NOTES / MEMO AMOUNT');
console.log('-------------------------------------------------------------------------------------------------------------------------');
const payees = await api.getPayees();
const accounts = await api.getAccounts();
( await api.runQuery(
api.q('transactions')
.filter({
date: { $gte: txDate },
})
.select('*')
)
).data.map(row => {
let payeeName = payees.find(p => p.id === row.payee)?.name;
let accountName = accounts.find(a => a.id === row.account)?.name;
console.log(row.date + ' ' +
fixedStringLength(accountName, 20) + ' ' +
fixedStringLength(payeeName, 25) + ' ' +
fixedStringLength(row.notes, 45) + ' ' +
('$' + (row.amount / 100).toFixed(2).trim()).padStart(11, ' '));
});
await closeBudget();
})();
function fixedStringLength(str, length) {
if (str === null) {
str = '';
}
return str.padEnd(length).substring(0, length);
}
+1 -1
View File
@@ -13,7 +13,7 @@ ACTUAL_CACHE_DIR="./cache"
# optional, name of the payee for added interest transactions
INTEREST_PAYEE_NAME="Loan Interest"
# optional, name of the payee for added investment transactions
# optional, name of the payee for added interest transactions
INVESTMENT_PAYEE_NAME="Investment"
# optional, name of the category group for added investment tracking transactions
INVESTMENT_CATEGORY_GROUP_NAME="Income"
+2 -3
View File
@@ -7,13 +7,12 @@ timedatectl set-timezone America/Chicago
# Perform update / upgrade of all existing packages and repos
apt update && apt upgrade -y
# Install curl and git if not present
# Install all identified dependencies
apt install curl git ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 \
libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 \
libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 \
libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 \
libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils \
apache2 php php-curl php-json php-cgi libapache2-mod-php -y
libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils -y
# Add NodeSource repository (replace 22.x with your desired version, e.g., 20.x, 18.x)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+247 -1184
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
{
"dependencies": {
"@actual-app/api": "latest",
"better-sqlite3": "^11.7.2",
"chromedriver": "^131.0.4",
"dotenv": "^16.4.5",
"jsdom": "^24.1.0",
-111
View File
@@ -1,111 +0,0 @@
const { closeBudget, openBudget, getTransactions, getAccountNote, getAccountBalance, getTagValue, ensurePayee } = require('./utils');
const api = require('@actual-app/api');
function getValueAtPath(obj, path) {
const keys = path.split('.').filter(Boolean);
return keys.reduce((acc, key) => {
const match = key.match(/^([^\[\]]+)(\[(\d+)\])?$/);
if (match) {
const property = match[1];
const index = match[3];
acc = acc[property];
if (index !== undefined) {
acc = acc[parseInt(index, 10)];
}
} else {
acc = acc[key];
}
return acc;
}, obj);
}
async function getCryptoPrice(crypto, krakenPath) {
const url = process.env[`CRYPTO_PRICE_URL_${crypto.toUpperCase()}`] || `https://api.kraken.com/0/public/Ticker?pair=${crypto}usd`;
if (!krakenPath && !process.env[`CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()}`]) {
console.error(`No Kraken path provided for ${crypto}. Please set CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()} environment variable or provide krakenPath argument.`);
return undefined;
}
const path = process.env[`CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()}`] || `result.${krakenPath}.c[0]`;
try {
const response = await fetch(url);
const json = await response.json();
return getValueAtPath(json, path);
} catch (error) {
console.error(`Error fetching price for ${crypto}:`, error);
return undefined;
}
}
(async () => {
await openBudget();
const payeeId = await ensurePayee(process.env.CRYPTO_PAYEE_NAME || 'Crypto Price Change');
const accounts = await api.getAccounts();
for (const account of accounts) {
if (account.closed) {
continue;
}
// Need to set cutoff date to tomorrow to ensure we get the latest transactions
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() + 1);
const note = await getAccountNote(account, cutoffDate);
// Get cryptos list from the note
let cryptos = getTagValue(note, 'CRYPTO', null);
if (!cryptos) continue;
cryptos = cryptos.split(',').map(c => c.trim()).filter(c => c.length > 0);
if (cryptos.length === 0) continue;
console.log(`Processing account: ${account.name}, Cryptos: ${cryptos.join(', ')}`);
let totalTargetBalance = 0;
for (const crypto of cryptos) {
const cryptoTag = getTagValue(note, crypto, 0.0);
if (!cryptoTag) continue;
cryptoTagSplit = cryptoTag.split(',');
// Get the amount from the first part of the tag
amount = parseFloat(cryptoTagSplit[0]);
if (isNaN(amount) || amount <= 0) continue;
console.log(`Crypto: ${crypto}, Amount: ${amount}`);
// Get Kraken path from the second part of the tag, if it exists
let krakenPath = cryptoTagSplit[1]
console.log(`Kraken Path: ${krakenPath}`);
// Get the crypto price
const cryptoPrice = await getCryptoPrice(crypto, krakenPath);
if (!cryptoPrice) {
console.error(`Unable to retrieve price for ${crypto}. Check your CRYPTO_PRICE_URL_${crypto.toUpperCase()} and CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()} environment variables`);
continue;
}
console.log(`Crypto: ${crypto}, Price: ${cryptoPrice}`);
const targetBalance = Math.round(cryptoPrice * amount * 100);
totalTargetBalance += targetBalance;
}
// Now we need to check if the total target balance is different from the current balance and update it
const currentBalance = await getAccountBalance(account, cutoffDate);
const diff = totalTargetBalance - currentBalance;
console.log(`Account: ${account.name}, Total Target Balance: ${totalTargetBalance}, Current Balance: ${currentBalance}, Diff: ${diff}`);
if (diff != 0) {
await api.importTransactions(account.id, [{
date: new Date(),
payee: payeeId,
amount: diff,
cleared: true,
reconciled: true,
notes: `Updated Crypto Price on ${new Date().toLocaleString()} (${cryptos.join(', ')})`,
}])
}
}
await closeBudget();
})();
-8
View File
@@ -16,7 +16,6 @@ const getCredentials = async () => {
const response = await fetch(url, { method: 'post' });
const api_url = await response.text();
try {
const rest = api_url.split('//', 2)[1];
const auth = rest.split('@', 1)[0];
const username = auth.split(':')[0];
@@ -27,10 +26,6 @@ const getCredentials = async () => {
fs.writeFileSync(cache + '/simplefin.credentials', data);
console.log('SimpleFIN credentials:', data);
return data;
} catch (err) {
console.log('Invalid SimpleFIN setup response:', api_url);
return undefined;
}
};
const loadCredentials = () => {
@@ -46,9 +41,6 @@ const getSimplefinBalances = async () => {
let credentials = loadCredentials();
if (!credentials) {
credentials = await getCredentials();
if (!credentials) {
return undefined;
}
}
const username = credentials.split(':')[0];
const pw = credentials.split(':')[1];
-61
View File
@@ -1,61 +0,0 @@
const api = require('@actual-app/api');
const fs = require('fs');
const readline = require('readline');
const { closeBudget, ensurePayee, getAccountBalance, getAccountNote, getTagValue, openBudget, showPercent, sleep,
getBudgetMonth, getCategoryGroups, getCategories } = require('./utils');
require("dotenv").config();
(async function () {
await openBudget();
const fileStream = fs.createReadStream("/var/www/html/data/investment-data.txt");
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
const payeeId = await ensurePayee('Change in Market Value');
for await (const line of rl) {
const parts = line.split('=');
const accountBalance = await api.getAccountBalance(parts[0]);
const adjustment = (accountBalance - parts[1].replace(/\./g, '')) * -1;
if (adjustment == 0) {
continue;
}
console.log(parts[0] + ' --> ' + parts[1] + ' --> $' + adjustment);
await api.importTransactions(parts[0], [{
date: new Date(),
payee: payeeId,
amount: adjustment,
cleared: true,
reconciled: true,
notes: `Change in Market Value`,
}]);
}
deleteFileIfExists('/var/www/html/data/investment-data.txt');
await closeBudget();
})();
function fixedStringLength(str, length) {
if (str === null) {
str = '';
}
return str.padEnd(length).substring(0, length);
}
async function deleteFileIfExists(filePath) {
try {
await fs.promises.unlink(filePath);
console.log(`File ${filePath} deleted successfully.`);
} catch (err) {
if (err.code === 'ENOENT') {
console.log(`File ${filePath} does not exist, so it was not deleted.`);
} else {
console.error('Error deleting file:', err.message);
}
}
}