To start with, let's clear up the clickbait: by “Actual Automation” I mean “Automation for Actual, the budgeting application”. 


I use Actual to manage my budgets. I've used a spreadsheet in the past but I prefer to use a dedicated self-hosted application for it. It looks like this:

If there's one thing I like to write code for, it's automating away unnecessary tedious work. Actual has some support for “Goal Templates” that allow you to automatically allocate your budgets with tags in the category's note like #template $50 to allocate $50 every month. I have more advanced goals for my budgeting automation:

  • First, set aside a budget for tithing to my church.
  • Second, set aside what I need for bills, gas, etc.
  • Then, divide my income by percentages into the following envelopes:
    • General
    • Social
    • Gifts
  • Finally, take what's left and put it into savings.

Goal templates cannot accomplish that. So, I wrote this!

// My budgeting app, Actual (https://actualbudget.org) has "Goal Templates" but I wanted more flexibility. I wrote this script that uses the Actual API to apply some custom rules to my budget:
// 1. Essentials:
//    * Set aside money for bills, and save up for bills that are not monthly
//    * Set aside money for tithing
//    * Set aside money for an emergency fund
//    * Set aside money for gas
// 2. Divide the remainder of my income into categories, rounding down to the nearest cent. The categories are as follows:
//    * 30% for general
//    * 20% for social
//    * 10% for gifts
// 3. Save the rest in a savings category, including any rounding errors from dividing the income.

import api from '@actual-app/api'

// dotenv is used for loading the password for Actual from a .env file.
import { config as dotenv } from 'dotenv'
dotenv()

// fs is used for resetting the cache prior to each run.
import fs from 'fs/promises'

// Some configuration is written as constants.
const DATA_DIR = './cache'
const SERVER_URL = 'https://actual.ts.wingysam.xyz'
const BUDGET_FILE_ID = 'budget-uuid-here'
const CURRENCY_UNITS = 100 // https://actualbudget.org/docs/api/reference#primitives


// These numbers are dummy data
const TITHE_PORTION = 0.10
const EMERGENCY = 500
const GAS = 200
const INCOME_DISTRIBUTION = [ // [fraction of income, category id]
  [0.30, 'General'],
  [0.20, 'Social'],
  [0.10, 'Gifts']
]
const BILL_AMOUNTS_AND_PERIODS = [ // [monthly cost, months to save up = 1]
  [100, 3], // Car Insurance, quarterly
  [30], // Haircut
  [50], // Phone Service
  [10], // Phone Payment
  [30], // Gym monthly
  [5, 12] // Gym annual
]

// The password to Actual and the month to apply the budgeting rules to are taken from env.
const { ACTUAL_PASSWORD, MONTH } = process.env
if (!ACTUAL_PASSWORD) throw new Error('Set ACTUAL_PASSWORD.')
if (!MONTH) throw new Error('Set MONTH.')

// The budget object and income are stored globally so they don't need to be recomputed or passed around.
let budget, income

main().catch(error => {
  log('Fatal Error:', error)
  process.exit(1)
})

async function main () {
  await resetCache()
  await api.init({
    dataDir: DATA_DIR,
    serverURL: SERVER_URL,
    password: ACTUAL_PASSWORD
  })
  await api.downloadBudget(BUDGET_FILE_ID)

  budget = await api.getBudgetMonth(MONTH)
  income = getCategory('Income').received

  await applyBudgetRules()
  await printBalances()

  await api.shutdown()
}

// Caching the budget saves a fraction of a second and introduces more complexity and potential for bugs than I'm willing to tolerate.
// As such, I wipe it every time the script runs.
async function resetCache () {
  await fs.rm(DATA_DIR, {
    recursive: true,
    force: true
  })
  await fs.mkdir(DATA_DIR)
}

async function applyBudgetRules() {
  // These don't depend on each other, so they can be done in parallel.
  await Promise.all([
    setAsideBills(),
    setAsideTithe(),
    setAsideEmergency(),
    setAsideGas(),
    clearSavingsBudget()
  ])
  
  // These depend on the previous steps, so they must be done in sequence.
  await divideIncome()
  await saveRemainder()
}

async function clearSavingsBudget() {
  await setBudgetAmount('Savings', 0)
}

// I want to budget for all of my bills every month.
// Some of my bills are quarterly or annual, so I save the cost per month of those bills.
// I don't want it to set the balance higher than (monthly cost * months to save up).
async function setAsideBills() {
  let billsPerMonth = 0
  let saveUpGoal = 0
  for (const [perMonth, monthsToSave = 1] of BILL_AMOUNTS_AND_PERIODS) {
    billsPerMonth += Math.round(perMonth * CURRENCY_UNITS)
    saveUpGoal += Math.round(perMonth * monthsToSave * CURRENCY_UNITS)
  }

  await addToCategoryBalance('Bills', billsPerMonth, saveUpGoal)
}

async function setAsideTithe() {
  await addToCategoryBalance('Tithe', Math.ceil(income * TITHE_PORTION))
}

async function setAsideEmergency() {
  await addToCategoryBalance('Emergency', EMERGENCY * CURRENCY_UNITS, EMERGENCY * CURRENCY_UNITS)
}

async function setAsideGas() {
  await addToCategoryBalance('Gas', GAS * CURRENCY_UNITS, GAS * CURRENCY_UNITS)
}

async function divideIncome() {
  for (const [_, categoryName] of INCOME_DISTRIBUTION) {
    await setBudgetAmount(categoryName, 0)
  }

  const remainder = budget.toBudget
  for (const [fraction, categoryName] of INCOME_DISTRIBUTION) {
    const amountToBudget = Math.floor(remainder * fraction)
    await addToCategoryBalance(categoryName, Math.max(0, amountToBudget))
  }
}

async function saveRemainder() {
  await addToCategoryBudget('Savings', budget.toBudget)
}

async function addToCategoryBalance(categoryName, amountToAllocate, maxToSaveUpTo = Number.MAX_SAFE_INTEGER) {
  const category = getCategory(categoryName)

  // I don't want this month's spending so far to impact the budget.
  const balanceAsOfEndOfLastMonth = category.balance - category.budgeted - category.spent

  const newBalance = Math.min(balanceAsOfEndOfLastMonth + amountToAllocate, maxToSaveUpTo)
  const newBudget = newBalance - balanceAsOfEndOfLastMonth

  await setBudgetAmount(categoryName, newBudget)
}

async function addToCategoryBudget(categoryName, amount) {
  const category = getCategory(categoryName)
  const newBudget = category.budgeted + amount

  await setBudgetAmount(categoryName, newBudget)
}

async function setBudgetAmount(categoryName, newBudget) {
  const category = getCategory(categoryName)
  await api.setBudgetAmount(MONTH, category.id, newBudget)

  // The API call doesn't update the budget object, so we need to update it manually.
  const oldBudget = category.budgeted
  category.budgeted = newBudget
  budget.toBudget -= newBudget - oldBudget
  category.balance += newBudget - oldBudget
}

function getCategory(categoryName) {
  const categories = budget.categoryGroups.flatMap(categoryGroup => categoryGroup.categories)
  const matches = categories.filter(category => category.name === categoryName)
  if (matches.length === 0) throw new Error(`Category not found: ${categoryName}`)
  if (matches.length > 1) throw new Error(`Duplicate categories found: ${categoryName}`)
  return matches[0]
}

async function printBalances() {
  log('New Balances:')
  for (const categoryGroup of budget.categoryGroups) {
    for (const category of categoryGroup.categories) {
      if (category.is_income) continue
      log(`${category.name}: ${formatCents(category.balance)}`)
    }
  }
}

// The script that invokes this program filters to lines that match /^> /. This is because Actual's SDK has some console.log statements that can't be turned off.
// I might open a PR to centralize the logging in the SDK at some point.
function log(...arg) {
  console.log('>', ...arg)
}

// The cents function turns a number of cents into a string formatted as a dollar amount.
// For example, 5000 cents becomes "$50.00". -5000 cents becomes "-$50.00".
function formatCents(cents) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  });
  return formatter.format(cents / 100);
}

So now, I can automatically do my budgeting for the month like this:

MONTH=2024-04 node src/index.js

But that's not good enough! So I used iOS Shortcuts with the “Run script over SSH” action:

So now, I open Siri and tell it to “Allocate Budget” and it automatically does all of my budgeting for the month in a few seconds! It also works from my Apple Watch. Here's an example with $2,000 of income:

Overall, I'm incredibly happy with what I've built. I can tell it to recompute the budget at any time and it tells me what I have available in each of my categories to spend.