haondt[blog]

Building My Perfect Budgeting Tool

Like many, when I first decided I needed to start budgeting my money, my first move was to load up a generic budget template in Google Sheets. By the time I had opened 3 or 4 bank accounts, updating the sheet had become extremely tedious. Reading through all my transactions, adding up the numbers, trying to make sure I didn't miss anything. It was time for me to graduate to something more powerful.



Firefly III

Always looking for something else to host on my home server, I settled on Firefly-III, a lovely PHP app written by the absolute legend James Cole. Firefly III was a huge improvement on my spreadsheet. The double entry bookkeeping system combined with the seperate data importer essentially eliminated the potential of entering transactions incorrectly, and it had substantially better UX as compared to a couple spreadsheets.

But there was still room to improve. At the top of the list was the rule system. When you import your transactions using the data importer, it can set either the source or the destination account, but not both. This is because the importer knows the one account being imported against, and it knows whether it is a deposit or withdrawal, but it can't determine the opposing account.

This is where the rule system comes in. A rule can update any part of a transaction (source/destination account, category, tags, etc) based on any other part. Great! I can create rules to set the opposing account by parsing the transaction description. Herein lies the issue. The available tools are simply not powerful enough.

  • The available comparators, starts with, ends with, equals, contains and the complement (not) of each, are not enough to determine the account name from the description. Regular expressions alone would fix this issue, but the author has explicitly refused to implement them.
  • The available combinators are too restrictive. You can effectively AND or OR all the rules together. I would like to at least nest comparisons.
  • The available actions are too limited. You can only set the opposing account with a hardcoded string, meaning you have to create at least one rule for every possible account. Due to the above limitations though, you generally have to create multiple rules for each account.


The Post-Processor

Enter Firefly-III-PP. This is a tool I built that leverages the Firefly III API to pull transactions from Firefly III, run them through a Node-Red flow and use the result to update them. It has a couple extra features as described in the GitHub link above, but the Node-Red tie-in is the star of the show here.

With the postprocessor add-on, my rules could do anything, but there was still one avenue of improvement left: convenience. With my current setup, I had to run 5 seperate docker containers:

  • Firefly III
  • MariaDB
  • Data Importer
  • Post-Processor
  • Node-Red

Due to the way it was designed, I had to run the post-processor and Node-Red flow locally, and the other containers on my server. Any time I had to recreate anything I'd have to go in and reconfigure the api keys, accounts, etc. To "open" it, I had to launch Docker Desktop, then start the containers, then open them in my browser. It was kind of a pain to maintain and use such a fragmented system.

Furthermore, Firefly III simply has too many features. I don't need multiple users, bills, piggy banks, recurring transactions, etc. I have a pretty basic budgeting philosophy (see below), and just need what is effectively a hosted spreadsheet with some automation.

So I wanted something that was more cohesive, and something with fewer features.


Midas

Midas is the result of taking the postprocessor to its logical conclusion. I used Sqlite for persistence, integrated the importer and made it a two-stage process that runs the csv through Node-Red, allows the user to review the result, and finally persists it in storage.

Beyond the transaction import, Midas more or less reaches feature parity with the postprocessor, and matches all the features from Firefly III I find to be useful. The data importing currently only supports csv, as I have security concerns with systems like Plaid. Open Banking in Canada is in the works, and I plan to add support for it if and when the day ever comes.


Budgeting Philosophies

There are a lot of ways to think of a budget - envelope-based, zero-based, thinking in terms of assets and liabilities, debts, investments, etc. In my (humble) opinion, it doesn't need to be so complicated.

My philosophy on budgeting can be summarized in four pillars:

  • minimize needs
  • maximize income
  • fixed ceiling on wants
  • fixed floor on savings

Under this pretense budgeting becomes a three step process.

  1. Do everything you can to reduce spending on "needs" as much as possible. At the same time, do everything you can to grow your income.
  2. After a couple months of this, take your income minus your needs to see what you have to work with. Slice out a piece of that (% or $ amount) and consider it saved.
  3. What's left can be spent freely.

Midas is built to enable this kind of budgeting. Categorization and report generation allows you to determine where money is going for steps 1 and 2.

For step 3 all you have to do is open the app and check your total income minus your total spending ("Cash Flow" on the dashboard). If this amount is greater than your savings goal, you can spend more. If this amount is less than your savings goal, you are spending too much.


Periodization

I generally break everything down month to month. Nothing in life really follows a strict schedule 100% of the time. Paycheques can be bi-weekly, semi-monthly, monthly or otherwise unpredictable. Expenses can vary significantly, one month I might fill up my car 4 times, the next month maybe not at all.

Ultimately I just need to pick a time unit to reason with, and a month seems pretty good. If a month has something very irregular (restocking something I only buy every few months, yearly recurring expenses, etc) then I might look at a number of months or years as the period. Midas allows you to select any period for both the dashboard and the reports, but defaults to month-to-month.


Flexibility

Earlier I said I felt Firefly III had too many features. Midas intentionally has very simple "units".

  • 2 Types of accounts: those included in your net worth, and those not.
  • 1 Type of transaction: from one account into another. There is no distinction between withdrawals, deposits and transfers.
  • No goals, budgets (ironic, I know), limits, or recurring transactions

This is because, at the end of the day, things vary. My paychecks aren't always the same amount, my bills aren't always the same amount, sometimes I have a sudden expense or a sudden injection of cash. All I really care about is that my net worth is trending upwards, ideally at the rate of at least 1x my savings goal / period. So most of the metrics in Midas are based around balance deltas rather than actual amounts.


The Tech Stack

Midas was an opportunity for me to figure out a couple different technologies I've been wanting to learn. First off, I took this as an opportunity to migrate my web nuget from Razor to Blazor. Oddly enough this made a large part of my web framework obselete, which cut down a lot on code. As opposed to my somewhat complicated previous setup, I can simply build my Razor (Blazor) component,

@code {
    [Parameter, EditorRequired]
    public required string InviteLink { get; set; }
}

<div class="field" style="width:100%;">
  <div class="control">
    <input 
        class="input"
        type="text"
        placeholder="Click &quot;Generate&quot; to generate a new link"
        value="@(!string.IsNullOrEmpty(Model.InviteLink) ? @Model.InviteLink : "")"
    >
  </div>
</div>

and immediately render it. No messing around with the service collection!

[Route("admin")]
public class AdminController(IComponentFactory componentFactory, IAdminService adminService)
{
    [HttpGet("generate-invite-component")]
    public async Task<IResult> GetGenerateInviteComponent()
    {
        return await componentFactory.RenderComponentAsync(new GenerateInviteModel
        {
            InviteLink = await adminService.GenerateInviteLinkAsync();
        });
    }
}

This is possible due to the RazorComponentResult introduced in .NET 8. Now it's not perfect, and there are some issues with the .razor editing experience, but overall this setup is very clean compared to what I was doing before. Perhaps I'll do a seperate post on my experience with it in the future.

I went with my standard htmx + hyperscript for interactivity on the frontend, with Bulma for the styling. The same setup I've been playing with in another project, Elysium.

For the charts I initially wanted something non-js-based, and gave Charts.css a solid effort, but ultimately I didn't like the way they looked. I looked around at a few different Blazor frameworks, but they are all essentially a C# wrapper around a javascript framework. I didn't want to add a whole nuget for just that, in the end I decided to go with Chart.js. I struggled a bit finding a sensical way to intergate it into my app, since I wanted to describe my chart data in C#. This meant I had to inject a C# model into a javascript model into a hyperscript snippet into a Razor component into an htmx response. On some occasions I needed to inject a javascript function into that C# model first. I wound up with this monstrosity:

<!-- Chart.razor -->

@using Midas.UI.Models.Charts

@code{
    [Parameter, EditorRequired]
    public required ChartConfiguration Configuration { get; set; }
}

<div style="position:relative;width:100%;height:100%;">
  <canvas
      _="
        on load
            js(me)
                return new Chart(me, @Configuration);
            end
            set :chart to it
        end

        on htmx:beforeCleanupElement
            set chart to :chart
            js(chart)
                chart.destroy();
            end
        end
        "></canvas>
</div>

The .ToString() method of ChartConfiguration is just a call to JsonConvert.SerializeObject. There were some interesting challenges in getting this to work correctly. A sampling:

  • Figuring out how to set up a callback on the htmx:beforeCleanupElement event to ensure the chart object gets cleaned up.
  • Figuring out what magic combination of css rules would play nice with Bulma Columns and allow the chart to scale responsively.
  • Writing a discriminated union type, Union<T1, T2>. Interestingly, it seems like an "official" implementation is coming soon.
  • Setting up some custom json converters to allow serialization of my custom types like Union<T1,T2> and of javascript functions.

Lastly, all the persistence is done with Sqlite. I had to write a lot of custom storage implementations for this project, which has me rethinking my strategy in my persistence nuget. I may need to revisit it later.


Closing Thoughts

Overall I'm pretty happy with what I've created here. I still have a backlog of features I'd like to add, but as it is currently it's at least usable. I might rework my persistence layer at some point too. There's a lot of repeated code in there and I don't really have a solution for db migrations. I am pretty satisfied with the UI/UX though, and look forward to putting it to use.