├── .gitignore ├── .env.sample ├── .github ├── dependabot.yml └── workflows │ └── docker-image.yml ├── Dockerfile ├── package.json ├── ghostfolio.js ├── .dockerignore ├── index.js ├── LICENSE ├── cli.js ├── index-cron.js ├── README.Docker.md ├── README.md ├── actual.js ├── engine.js └── config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | .env 4 | temp_data_actual/ -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | INTEREST_RATE= 2 | MORTGAGE_PAYEE_ID= 3 | MORTGAGE_ACCOUNT_ID= 4 | MAIN_ACCOUNT_ID= 5 | 6 | PAYEE_REGEX_MATCH= 7 | 8 | GHOSTFOLIO_ACCOUNT= 9 | GHOSTFOLIO_ACTUAL_ACCOUNT= 10 | GHOSTFOLIO_ACTUAL_PAYEE_NAME= 11 | GHOSTFOLIO_ACCOUNT_1= 12 | GHOSTFOLIO_ACTUAL_ACCOUNT_1= 13 | GHOSTFOLIO_ACTUAL_PAYEE_NAME_1= 14 | GHOSTFOLIO_SERVER_URL= 15 | GHOSTFOLIO_TOKEN= 16 | ENABLE_GHOSTFOLIO_SYNC= 17 | ENABLE_BANK_SYNC= 18 | 19 | ACTUAL_SERVER_URL="server url" 20 | ACTUAL_SERVER_PASSWORD="server password" 21 | ACTUAL_SYNC_ID= 22 | ACTUAL_FILE_PASSWORD="" 23 | 24 | CRON_EXPRESSION= -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "daily" 10 | 11 | # Enable version updates for Docker 12 | - package-ecosystem: "docker" 13 | # Look for a `Dockerfile` in the `root` directory 14 | directory: "/" 15 | # Check for updates once a week 16 | schedule: 17 | interval: "weekly" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=24.11.0 2 | 3 | FROM node:${NODE_VERSION}-alpine AS BUILD_IMAGE 4 | 5 | WORKDIR /usr/src/app 6 | 7 | COPY package*.json ./ 8 | 9 | RUN npm install --omit=dev 10 | RUN npm ci --omit=dev 11 | 12 | COPY . . 13 | 14 | FROM node:${NODE_VERSION}-alpine AS RUNNER_IMAGE 15 | 16 | WORKDIR /usr/src/app 17 | 18 | COPY --from=BUILD_IMAGE /usr/src/app/node_modules ./node_modules 19 | ADD . . 20 | ADD package*.json ./ 21 | 22 | 23 | RUN chmod +x index-cron.js 24 | 25 | ENV NODE_ENV production 26 | 27 | # Run the application. 28 | CMD node index-cron.js 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actual-tasks", 3 | "version": "0.0.5", 4 | "description": "Run some tasks that iteract with Actual Budget", 5 | "main": "index.js", 6 | "bin": { 7 | "actual-tasks": "index.js" 8 | }, 9 | "author": "Tiago Rodrigues", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@actual-app/api": "^25.12.0", 13 | "better-opn": "^3.0.2", 14 | "cron-parser": "^5.4.0", 15 | "dotenv": "^17.2.3", 16 | "meow": "^14.0.0", 17 | "node-cron": "^4.2.1", 18 | "node-fetch": "^3.3.2", 19 | "ssl-root-cas": "^1.3.1", 20 | "terminal-link": "^5.0.0" 21 | }, 22 | "engines": { 23 | "node": ">=22" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ghostfolio.js: -------------------------------------------------------------------------------- 1 | const { getAppConfigFromEnv } = require("./config"); 2 | 3 | const appConfig = getAppConfigFromEnv(); 4 | 5 | 6 | async function getAccountsBalance() { 7 | token = 'Bearer ' + appConfig.GHOSTFOLIO_TOKEN 8 | url = appConfig.GHOSTFOLIO_SERVER_URL+ '/api/v1/account' 9 | accounts = await fetch(url, { 10 | method: 'GET', 11 | headers: { 12 | 'Authorization': token 13 | }, 14 | }) 15 | .then((response) => response.json()) 16 | .then((json) => json.accounts) 17 | .catch((err) => { 18 | console.error("error occured", err); 19 | }); 20 | 21 | return accounts; 22 | } 23 | 24 | module.exports = { 25 | getAccountsBalance 26 | } 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/go/build-context-dockerignore/ 6 | 7 | **/.classpath 8 | **/.dockerignore 9 | **/.env 10 | **/.git 11 | **/.gitignore 12 | **/.project 13 | **/.settings 14 | **/.toolstarget 15 | **/.vs 16 | **/.vscode 17 | **/.next 18 | **/.cache 19 | **/*.*proj.user 20 | **/*.dbmdl 21 | **/*.jfm 22 | **/charts 23 | **/docker-compose* 24 | **/compose* 25 | **/Dockerfile* 26 | **/node_modules 27 | **/npm-debug.log 28 | **/obj 29 | **/secrets.dev.yaml 30 | **/values.dev.yaml 31 | **/build 32 | **/dist 33 | LICENSE 34 | README.md 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import meow from 'meow'; 3 | import engine from './cli.js'; 4 | 5 | const cli = meow( 6 | ` 7 | Usage 8 | $ actualtasks 9 | 10 | Commands & Options 11 | config Print the location of actualtasks the config file 12 | fix-payees Apply the regex configured and remove it 13 | calculate-mortgage Calculate the principal from the mortgage payment 14 | ghostfolio-sync Sync Ghostfolio balance accounts with actual accounts 15 | hold-amout-for-next-month Call sync method 16 | 17 | 18 | Options for all commands 19 | --user, -u Specify the user to load configs for 20 | 21 | Examples 22 | $ actualtasks config 23 | `, 24 | { 25 | importMeta: import.meta, 26 | }); 27 | 28 | engine(cli.input[0]); 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 rodriguestiago0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | const { fixPayees, calculateMortgage, ghostfolioSync, bankSync } = require("./engine.js"); 2 | 3 | let config; 4 | 5 | /** 6 | * 7 | * @param {string} command 8 | * @param {object} flags 9 | * @param {string} flags.since 10 | */ 11 | module.exports = async (command) => { 12 | if (!command) { 13 | console.log('Try "actualtasks --help"'); 14 | process.exit(); 15 | } 16 | 17 | if (command === "fix-payees") { 18 | try{ 19 | await fixPayees(); 20 | } catch (e){ 21 | console.error(e); 22 | } 23 | } else if (command === "calculate-mortgage") { 24 | try{ 25 | await calculateMortgage(); 26 | } catch (e){ 27 | console.error(e); 28 | } 29 | } else if (command === "ghostfolio-sync") { 30 | try{ 31 | await ghostfolioSync(); 32 | } catch (e){ 33 | console.error(e); 34 | } 35 | } else if (command === "bank-sync") { 36 | try{ 37 | await bankSync(); 38 | } catch (e){ 39 | console.error(e); 40 | } 41 | } 42 | process.exit(); 43 | }; 44 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Publish 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: [ 'v*.*.*' ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v3 19 | # Workaround: https://github.com/docker/build-push-action/issues/461 20 | - name: Setup Docker buildx 21 | uses: docker/setup-buildx-action@v2 22 | 23 | - name: Log into registry 24 | uses: docker/login-action@v2 25 | with: 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | 29 | - name: Extract Docker metadata 30 | id: meta 31 | uses: docker/metadata-action@v4 32 | with: 33 | images: rodriguestiago0/actualtasks 34 | 35 | 36 | - name: Build and push Docker image 37 | id: build-and-push 38 | uses: docker/build-push-action@v4 39 | with: 40 | context: . 41 | file: ./Dockerfile 42 | push: ${{ github.event_name != 'pull_request' }} # Don't push on PR 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /index-cron.js: -------------------------------------------------------------------------------- 1 | const { fixPayees, calculateMortgage, ghostfolioSync, bankSync } = require("./engine.js"); 2 | var cron = require('node-cron'); 3 | const parser = require('cron-parser'); 4 | const { getAppConfigFromEnv } = require("./config"); 5 | 6 | const appConfig = getAppConfigFromEnv(); 7 | 8 | var cronExpression = "0 */4 * * *"; 9 | if (appConfig.CRON_EXPRESSION != "") { 10 | cronExpression = appConfig.CRON_EXPRESSION; 11 | } 12 | console.info("Defined cron is: ", cronExpression) 13 | const interval = parser.CronExpressionParser.parse(cronExpression); 14 | console.info('Next run:', interval.next().toISOString()); 15 | 16 | if (appConfig.ENABLE_INTEREST_CALCULATION) { 17 | console.info("Task interest calculation enabled"); 18 | } 19 | 20 | if (appConfig.ENABLE_PAYEE_RENAME) { 21 | console.info("Task fix payees enabled"); 22 | } 23 | 24 | if (appConfig.ENABLE_GHOSTFOLIO_SYNC) { 25 | console.info("Task ghostfolio sync enabled"); 26 | } 27 | 28 | 29 | if (appConfig.ENABLE_BANK_SYNC) { 30 | console.info("Task bank sync enabled"); 31 | } 32 | 33 | cron.schedule(cronExpression, async () => { 34 | if (appConfig.ENABLE_INTEREST_CALCULATION) { 35 | console.info("Running interest calculation"); 36 | await calculateMortgage(); 37 | } 38 | 39 | if (appConfig.ENABLE_PAYEE_RENAME) { 40 | console.info("Running fix payees"); 41 | await fixPayees(); 42 | } 43 | 44 | if (appConfig.ENABLE_GHOSTFOLIO_SYNC) { 45 | console.info("Running ghostfolio sync"); 46 | await ghostfolioSync(); 47 | } 48 | 49 | 50 | if (appConfig.ENABLE_BANK_SYNC){ 51 | console.info("Running bank sync"); 52 | await bankSync(); 53 | } 54 | console.info('Next run:', interval.next().toISOString()); 55 | }); 56 | 57 | -------------------------------------------------------------------------------- /README.Docker.md: -------------------------------------------------------------------------------- 1 | ### Building and running your application 2 | 3 | When you're ready, start your application by running: 4 | `docker compose up --build`. 5 | 6 | Docker-comple.yaml example: 7 | ``` 8 | version: '3' 9 | services: 10 | actual_server: 11 | container_name: actualtasks 12 | image: docker.io/rodriguestiago0/actualtasks 13 | environment: 14 | - PUID=1003 15 | - PGID=100 16 | - TZ=Europe/Lisbon 17 | - INTEREST_RATE= 18 | - MORTGAGE_PAYEE_ID= 19 | - MORTGAGE_ACCOUNT_ID= 20 | - MAIN_ACCOUNT_ID= 21 | - PAYEE_REGEX_MATCH= 22 | - ACTUAL_SERVER_URL= 23 | - ACTUAL_SERVER_PASSWORD= 24 | - ACTUAL_FILE_PASSWORD= 25 | - ACTUAL_SYNC_ID= 26 | - ENABLE_INTEREST_CALCULATION=true 27 | - ENABLE_PAYEE_RENAME=true 28 | - CRON_EXPRESSION= # default value is "0 */4 * * *" 29 | - GHOSTFOLIO_ACCOUNT= 30 | - GHOSTFOLIO_ACTUAL_ACCOUNT= 31 | - GHOSTFOLIO_ACTUAL_PAYEE_NAME= 32 | - ENABLE_GHOSTFOLIO_SYNC=true 33 | - GHOSTFOLIO_SERVER_URL= 34 | - GHOSTFOLIO_TOKEN= 35 | - ENABLE_BANK_SYNC=true 36 | restart: unless-stopped 37 | ``` 38 | 39 | ``` 40 | docker run -d --name actualtasks \ 41 | - e 'INTEREST_RATE=' \ 42 | - e 'MORTGAGE_PAYEE_ID=' \ 43 | - e 'MAIN_ACCOUNT_ID=' \ 44 | - e 'MORTGAGE_ACCOUNT_ID=' \ 45 | - e' ENABLE_INTEREST_CALCULATION'=true \ 46 | - e 'ENABLE_PAYEE_RENAME'=true \ 47 | - e 'PAYEE_REGEX_MATCH=' \ 48 | - e 'ACTUAL_SERVER_URL= ' \ 49 | - e 'ACTUAL_SERVER_PASSWORD=' \ 50 | - e 'ACTUAL_FILE_PASSWORD=' \ 51 | - e 'ACTUAL_SYNC_ID=' \ 52 | - e 'CRON_EXPRESSION'= # default value is "0 */4 * '* *"' \ 53 | - e 'GHOSTFOLIO_ACCOUNT'= \ 54 | - e 'GHOSTFOLIO_ACTUAL_ACCOUNT'= \ 55 | - e 'GHOSTFOLIO_ACTUAL_PAYEE_NAME'= \ 56 | - e 'ENABLE_GHOSTFOLIO_SYNC'=true \ 57 | - e 'GHOSTFOLIO_SERVER_URL'= \ 58 | - e 'GHOSTFOLIO_TOKEN'= \ 59 | - e 'ENABLE_BANK_SYNC'=true \ 60 | --restart=on-failure rodriguestiago0/actualtasks:latest 61 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Actual Tasks 2 | This is a project that contains two tasks. You can trigger them manually or setup a docker container that will run them periodically. 3 | 4 | ## Tasks: 5 | 6 | ### Fix payee name based on a regex expression: 7 | **Input**: `Compra 1234 Continente Porto Contactless` 8 | 9 | **Regex**: `Compra [0-9]{4}|Contactless|Compra:|[0-9]{4}-[0-9]{0,3}` 10 | 11 | **Output**: `Continente Porto` 12 | 13 | This task will also remove extra space in the payee name. 14 | **Input**: `Continente Porto` 15 | 16 | **Output**: `Continente Porto` 17 | 18 | **Note:** In the end it will merge payees with the exact same name 19 | 20 | ### Create a transaction that deducts your payment mortgage 21 | To configure the correct interest rate is the % multiple by 1000 22 | 23 | ### Sync ghostfolio account with Actual Budget account 24 | Create a new transaction to keep ghostfolio account balance and actual account balance in sync 25 | 26 | ### Run bank sync 27 | Run bank sync 28 | 29 | 30 | ## Setup 31 | 32 | - Clone this repo! 33 | - Install dependencies: `npm ci` 34 | - Copy `.sample.env` to `.env` and fill in the blanks 35 | 36 | ## Some things worth noting 37 | 38 | This is a repository where I will be adding tasks to run periodically. For now both fixing payees name and calculate mortgage are enabled by default. 39 | 40 | ## Commands 41 | 42 | 43 | ``` 44 | Usage 45 | $ actual-tasks 46 | 47 | Commands & Options 48 | config Print the location of actualplaid the config file 49 | fix-payees Will apply the regex and replace for empty string 50 | calculate-mortgage Will check the mortgage balance and calculate the principal payment based on your montly payment 51 | ghostfolio-sync Will check ghost balance account and actual account balances and create a transaction in actual to sync both balances. 52 | hold-amout-for-next-month Sum all income transaction and hold it to next month budget 53 | bank-sync Will run the bank sync 54 | 55 | 56 | Options for all commands 57 | --user, -u Specify the user to load configs for 58 | Examples 59 | $ actual-tasks config 60 | ``` 61 | -------------------------------------------------------------------------------- /actual.js: -------------------------------------------------------------------------------- 1 | const { getAppConfigFromEnv } = require("./config"); 2 | const actual = require("@actual-app/api"); 3 | const fs = require("fs"); 4 | let { q } = require('@actual-app/api'); 5 | 6 | 7 | const appConfig = getAppConfigFromEnv(); 8 | 9 | /** 10 | * 11 | * @returns {Promise} 12 | */ 13 | async function initialize() { 14 | try { 15 | const tmp_dir = `./temp_data_actual/${appConfig.ACTUAL_SYNC_ID}` 16 | if (fs.existsSync(tmp_dir)) { 17 | console.log("delete temp dir"); 18 | fs.rmSync(tmp_dir, {recursive: true}) 19 | } 20 | fs.mkdirSync(tmp_dir, { recursive: true }); 21 | 22 | await actual.init({ 23 | serverURL: appConfig.ACTUAL_SERVER_URL, 24 | password: appConfig.ACTUAL_SERVER_PASSWORD, 25 | dataDir: tmp_dir 26 | }); 27 | 28 | let id = appConfig.ACTUAL_SYNC_ID 29 | var passwordConfig = {}; 30 | if (appConfig.ACTUAL_FILE_PASSWORD) { 31 | passwordConfig = { 32 | password: appConfig.ACTUAL_FILE_PASSWORD 33 | } 34 | } 35 | await actual.downloadBudget(id, passwordConfig); 36 | } catch (e) { 37 | throw new Error(`Actual Budget Error: ${e.message}`); 38 | } 39 | 40 | console.log("initialize"); 41 | return actual; 42 | } 43 | 44 | /** 45 | * 46 | * @param {typeof actual} actualInstance 47 | */ 48 | function listAccounts(actualInstance) { 49 | return actualInstance.getAccounts(); 50 | } 51 | 52 | /** 53 | * 54 | * @param {typeof actual} actualInstance 55 | */ 56 | async function getPayees(actualInstance, regexExpressionToMatch) { 57 | if (regexExpressionToMatch == "") { 58 | return [] 59 | } 60 | payees = await actualInstance.getPayees(); 61 | toBeUpdated = [] 62 | payees.forEach(payee => { 63 | if (payee.name.match(regexExpressionToMatch)) { 64 | toBeUpdated.push(payee) 65 | } 66 | }); 67 | return toBeUpdated; 68 | } 69 | 70 | /** 71 | * 72 | * @param {typeof actual} actualInstance 73 | */ 74 | async function getAllPayees(actualInstance) { 75 | return await actualInstance.getPayees(); 76 | } 77 | 78 | /** 79 | * 80 | * @param {typeof actual} actualInstance 81 | */ 82 | async function updatePayees(actualInstance, payeesToUpdate) { 83 | for (id of Object.keys(payeesToUpdate)) { 84 | await actualInstance.updatePayee(id, { 85 | "name": payeesToUpdate[id] 86 | }); 87 | } 88 | } 89 | 90 | /** 91 | * @param {typeof actual} actualInstance 92 | * @param {*} accountId 93 | */ 94 | async function getLastTransaction(actualInstance, accountId, payeeID) { 95 | const monthAgo = new Date(); 96 | monthAgo.setMonth(monthAgo.getMonth() -1); 97 | 98 | const transactions = await actualInstance.getTransactions(accountId, monthAgo, new Date()); 99 | filteredTransactions = transactions; 100 | 101 | if (payeeID != undefined && payeeID != null && payeeID != "") { 102 | filteredTransactions = filteredTransactions.filter(transaction => { 103 | return transaction.payee == payeeID 104 | }) 105 | } 106 | 107 | if (filteredTransactions.length === 0) { 108 | return null; 109 | } 110 | 111 | return filteredTransactions[0]; 112 | } 113 | 114 | /** 115 | * @param {typeof actual} actualInstance 116 | * @param {*} accountId 117 | * @param {*} transactions 118 | */ 119 | async function importTransactions(actualInstance, accountId, transactions) { 120 | console.info("Importing transactions raw data START:") 121 | console.debug(transactions) 122 | const actualResult = await actualInstance.importTransactions( 123 | accountId, 124 | transactions 125 | ); 126 | console.info("Actual logs: ", actualResult); 127 | } 128 | 129 | /** 130 | * @param {typeof actual} actualInstance 131 | * @param {*} accountId 132 | */ 133 | async function getAccountBalance(actualInstance, accountId) { 134 | const balance = await actualInstance.getAccountBalance(accountId); 135 | return balance; 136 | } 137 | 138 | 139 | /** 140 | * @param {typeof actual} actualInstance 141 | * @param {*} payeeIDs 142 | */ 143 | async function mergePayees(actualInstance, payeeIDs) { 144 | if (payeeIDs.length < 2) return; 145 | const targetID = payeeIDs[0] 146 | const mergeIds = payeeIDs.slice(1) 147 | await actualInstance.mergePayees(targetID, mergeIds); 148 | } 149 | 150 | /** 151 | * 152 | * @param {typeof actual} actualInstance 153 | */ 154 | async function finalize(actualInstance) { 155 | await actualInstance.sync() 156 | await actualInstance.shutdown(); 157 | 158 | console.log("finalize"); 159 | } 160 | 161 | module.exports = { 162 | initialize, 163 | listAccounts, 164 | importTransactions, 165 | getAccountBalance, 166 | getPayees, 167 | getAllPayees, 168 | updatePayees, 169 | mergePayees, 170 | getLastTransaction, 171 | finalize 172 | } 173 | -------------------------------------------------------------------------------- /engine.js: -------------------------------------------------------------------------------- 1 | const { getAppConfigFromEnv } = require("./config"); 2 | const { initialize, getPayees, getAllPayees, updatePayees, getLastTransaction, finalize, getAccountBalance, importTransactions, mergePayees} = require("./actual.js"); 3 | const ghostfolio = require("./ghostfolio.js"); 4 | 5 | const appConfig = getAppConfigFromEnv(); 6 | 7 | async function fixPayees() { 8 | console.log("Fix payees name"); 9 | const actual = await initialize(); 10 | payees = await getPayees(actual, appConfig.PAYEE_REGEX_MATCH) 11 | updatedPayee = {} 12 | payees.forEach(payee => { 13 | let name = payee.name; 14 | if (appConfig.PAYEE_REGEX_MATCH != "") 15 | { 16 | name = payee.name.replace(new RegExp(appConfig.PAYEE_REGEX_MATCH, "gis"), ""); 17 | } 18 | name = name.replace(new RegExp(" {2,}", "gis"), " ").trim() 19 | if (name != payee.name) 20 | { 21 | updatedPayee[payee.id] = name; 22 | console.log ("Update payee from " + payee.name + " to " + name) 23 | } 24 | }); 25 | 26 | 27 | await finalize(actual); 28 | 29 | console.log("Fix repeated payees"); 30 | 31 | const actual2 = await initialize(); 32 | 33 | await updatePayees(actual2, updatedPayee) 34 | 35 | const payeesToMerge = new Map(); 36 | allPayees = await getAllPayees(actual2) 37 | allPayees.forEach(payee => { 38 | if (payee.transfer_acct != null) { 39 | return; 40 | } 41 | let ids = []; 42 | if (payeesToMerge.has(payee.name)){ 43 | ids = payeesToMerge.get(payee.name); 44 | ids.push(payee.id); 45 | } else { 46 | ids.push(payee.id); 47 | } 48 | payeesToMerge.set(payee.name, ids); 49 | }) 50 | 51 | for (let [name, ids] of payeesToMerge) { 52 | if (ids.length > 1) { 53 | console.log("Merge payees", name, ids); 54 | await mergePayees(actual2, ids); 55 | } 56 | } 57 | 58 | await finalize(actual2); 59 | } 60 | 61 | function padTo2Digits(num) { 62 | return num.toString().padStart(2, '0'); 63 | } 64 | 65 | function formatDate(date) { 66 | return [ 67 | date.getFullYear(), 68 | padTo2Digits(date.getMonth() + 1), 69 | padTo2Digits(date.getDate()), 70 | ].join('-'); 71 | } 72 | 73 | async function calculateMortgage() { 74 | const actual = await initialize(); 75 | lastPaymentTransaction = await getLastTransaction(actual, appConfig.MAIN_ACCOUNT_ID, appConfig.MORTGAGE_PAYEE_ID); 76 | lastPrincipalTransaction = await getLastTransaction(actual, appConfig.MORTGAGE_ACCOUNT_ID, appConfig.MORTGAGE_PAYEE_ID); 77 | 78 | if (lastPrincipalTransaction == null || new Date(lastPaymentTransaction.date).getMonth() != new Date(lastPrincipalTransaction.date).getMonth()) 79 | { 80 | balance = await getAccountBalance(actual, appConfig.MORTGAGE_ACCOUNT_ID); 81 | console.log(lastPaymentTransaction, lastPrincipalTransaction, balance) 82 | payment = Math.round(balance * appConfig.INTEREST_RATE / 12 / 100000 * -1); 83 | principal = (lastPaymentTransaction.amount + payment) * -1; 84 | await importTransactions(actual, appConfig.MORTGAGE_ACCOUNT_ID,[ 85 | { 86 | date: formatDate(new Date()), 87 | amount: principal, 88 | payee_name: appConfig.MORTGAGE_PAYEE_NAME, 89 | }, 90 | ]) 91 | } 92 | await finalize(actual); 93 | } 94 | 95 | async function ghostfolioSync() { 96 | const actual = await initialize(); 97 | const ghostfolioAccounts = await ghostfolio.getAccountsBalance(); 98 | for (const [ghostfolioAccount, actualAccount] of Object.entries(appConfig.GHOSTFOLIO_ACCOUNT_MAPPING)) { 99 | actualBalance = await getAccountBalance(actual, actualAccount); 100 | ghostfolioAccountDetails = ghostfolioAccounts.filter((account) => account.id == ghostfolioAccount); 101 | ghostfolioBalance = Math.floor(ghostfolioAccountDetails[0].value*100); 102 | account = appConfig.GHOSTFOLIO_ACTUAL_PAYEE_NAME_MAPPING[ghostfolioAccount]; 103 | if (actualBalance != ghostfolioBalance) { 104 | await importTransactions(actual, actualAccount, [ 105 | { 106 | date: formatDate(new Date()), 107 | amount: ghostfolioBalance-actualBalance, 108 | payee_name: account, 109 | } 110 | ]) 111 | } else { 112 | console.log("No difference found for account " + account + '(' + actualBalance + ')' + '(' + actualBalance + ')'); 113 | } 114 | } 115 | await finalize(actual); 116 | } 117 | 118 | 119 | async function bankSync() { 120 | const actual = await initialize(); 121 | await actual.runBankSync() 122 | await finalize(actual); 123 | } 124 | 125 | module.exports = { 126 | fixPayees, 127 | calculateMortgage, 128 | ghostfolioSync, 129 | bankSync 130 | } 131 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const INTEREST_RATE = process.env.INTEREST_RATE || ""; 4 | const MORTGAGE_PAYEE_ID = process.env.MORTGAGE_PAYEE_ID || ""; 5 | const MORTGAGE_PAYEE_NAME = process.env.MORTGAGE_PAYEE_NAME || ""; 6 | const MORTGAGE_ACCOUNT_ID = process.env.MORTGAGE_ACCOUNT_ID || ""; 7 | const MAIN_ACCOUNT_ID = process.env.MAIN_ACCOUNT_ID || ""; 8 | const GHOSTFOLIO_SERVER_URL = process.env.GHOSTFOLIO_SERVER_URL || ""; 9 | const GHOSTFOLIO_TOKEN = process.env.GHOSTFOLIO_TOKEN || ""; 10 | 11 | const PAYEE_REGEX_MATCH = process.env.PAYEE_REGEX_MATCH || ""; 12 | 13 | const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || ""; 14 | const ACTUAL_SERVER_PASSWORD = process.env.ACTUAL_SERVER_PASSWORD || ""; 15 | const ACTUAL_FILE_PASSWORD = process.env.ACTUAL_FILE_PASSWORD || ""; 16 | 17 | 18 | const CRON_EXPRESSION = process.env.CRON_EXPRESSION || "0 */4 * * *"; 19 | const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID || ""; 20 | 21 | const ENABLE_INTEREST_CALCULATION= stringToBoolean(process.env.ENABLE_INTEREST_CALCULATION); 22 | const ENABLE_PAYEE_RENAME = stringToBoolean(process.env.ENABLE_PAYEE_RENAME); 23 | const ENABLE_BANK_SYNC = stringToBoolean(process.env.ENABLE_BANK_SYNC); 24 | const ENABLE_GHOSTFOLIO_SYNC = stringToBoolean(process.env.ENABLE_GHOSTFOLIO_SYNC); 25 | 26 | 27 | function stringToBoolean(stringValue){ 28 | switch(stringValue?.toLowerCase()?.trim()){ 29 | case "true": 30 | case "yes": 31 | case "1": 32 | return true; 33 | 34 | case "false": 35 | case "no": 36 | case "0": 37 | case null: 38 | case undefined: 39 | return false; 40 | 41 | default: 42 | return JSON.parse(stringValue); 43 | } 44 | } 45 | 46 | function validateEnv(variables){ 47 | // Assert that all required environment variables are set 48 | Object.entries(variables).forEach(([key, value]) => { 49 | if (!value) { 50 | throw new Error(`Missing environment variable: ${key}`); 51 | } 52 | }) 53 | } 54 | 55 | function getAppConfigFromEnv() { 56 | validateEnv({ 57 | ACTUAL_SERVER_URL, 58 | ACTUAL_SERVER_PASSWORD, 59 | ACTUAL_SYNC_ID, 60 | CRON_EXPRESSION, 61 | }) 62 | 63 | if (ENABLE_INTEREST_CALCULATION) { 64 | validateEnv({ 65 | MORTGAGE_PAYEE_ID, 66 | MORTGAGE_PAYEE_NAME, 67 | MORTGAGE_ACCOUNT_ID, 68 | MAIN_ACCOUNT_ID, 69 | INTEREST_RATE, 70 | }) 71 | } 72 | 73 | if (ENABLE_PAYEE_RENAME) { 74 | validateEnv({ 75 | PAYEE_REGEX_MATCH, 76 | }) 77 | } 78 | 79 | GHOSTFOLIO_ACCOUNT_MAPPING = {} 80 | GHOSTFOLIO_ACTUAL_PAYEE_NAME_MAPPING = {} 81 | if (ENABLE_GHOSTFOLIO_SYNC) { 82 | 83 | ghostfolioAccount = process.env.GHOSTFOLIO_ACCOUNT 84 | actualAccount = process.env.GHOSTFOLIO_ACTUAL_ACCOUNT 85 | ghotfolioPayeeName = process.env.GHOSTFOLIO_ACTUAL_PAYEE_NAME 86 | 87 | if (!ghostfolioAccount){ 88 | throw new Error(`Missing environment variable: GHOSTFOLIO_ACCOUNT`); 89 | } 90 | 91 | if (!actualAccount){ 92 | throw new Error(`Missing environment variable: GHOSTFOLIO_ACTUAL_ACCOUNT`); 93 | } 94 | 95 | if (!ghotfolioPayeeName){ 96 | throw new Error(`Missing environment variable: GHOSTFOLIO_ACTUAL_PAYEE_NAME`); 97 | } 98 | 99 | GHOSTFOLIO_ACCOUNT_MAPPING[ghostfolioAccount] = actualAccount; 100 | GHOSTFOLIO_ACTUAL_PAYEE_NAME_MAPPING[ghostfolioAccount] = ghotfolioPayeeName; 101 | var i = 1; 102 | while(true){ 103 | ghostfolioAccount = process.env[`GHOSTFOLIO_ACCOUNT_${i}`] || "" 104 | actualAccount = process.env[`GHOSTFOLIO_ACTUAL_ACCOUNT_${i}`] || "" 105 | ghotfolioPayeeName = process.env[`GHOSTFOLIO_ACTUAL_PAYEE_NAME_${i}`] || "" 106 | if (!ghostfolioAccount || !actualAccount || !ghotfolioPayeeName) { 107 | break; 108 | } 109 | i++; 110 | GHOSTFOLIO_ACCOUNT_MAPPING[ghostfolioAccount] = actualAccount; 111 | GHOSTFOLIO_ACTUAL_PAYEE_NAME_MAPPING[ghostfolioAccount] = ghotfolioPayeeName; 112 | } 113 | 114 | validateEnv({ 115 | GHOSTFOLIO_SERVER_URL, 116 | GHOSTFOLIO_TOKEN, 117 | }) 118 | } 119 | 120 | const appConfig = { 121 | ACTUAL_SERVER_URL, 122 | ACTUAL_SERVER_PASSWORD, 123 | ACTUAL_FILE_PASSWORD, 124 | ACTUAL_SYNC_ID, 125 | CRON_EXPRESSION, 126 | PAYEE_REGEX_MATCH, 127 | MORTGAGE_PAYEE_ID, 128 | MORTGAGE_PAYEE_NAME, 129 | MORTGAGE_ACCOUNT_ID, 130 | MAIN_ACCOUNT_ID, 131 | INTEREST_RATE, 132 | ENABLE_INTEREST_CALCULATION, 133 | ENABLE_PAYEE_RENAME, 134 | ENABLE_GHOSTFOLIO_SYNC, 135 | GHOSTFOLIO_ACCOUNT_MAPPING, 136 | GHOSTFOLIO_ACTUAL_PAYEE_NAME_MAPPING, 137 | GHOSTFOLIO_SERVER_URL, 138 | GHOSTFOLIO_TOKEN, 139 | ENABLE_BANK_SYNC 140 | } 141 | 142 | return appConfig 143 | } 144 | 145 | 146 | module.exports = { 147 | getAppConfigFromEnv 148 | } 149 | --------------------------------------------------------------------------------