Not getting financial insight

The new domain model after adding counter party to transaction. This allows me to get financial insight.

TL;DR

In this post I describe how I discover some imperfections in the current domain. This hampers me in getting financial insight. I fix these imperfections by creating new Category rules. After creating new rules I refactor the rules in to a simpler form. Furthermore I fix Transaction in the domain. As the found issues are mitigated I now have even more insight.

My main learning is that you can design a domain to the best of your ability, however using it will really expose issues. Find the resulting code, once more, at GitHub.

Goal for this year

I still want to reach financial independence some time in the future. In order to reach financial independence I need to get insight in my current spending and use budgets to control these spendings. I formulated this in the ubiquitous language:

To achieve financial insight the application needs to import and categorize financial transactions. I want to set budgets for a year and compare them to the actual amounts. Furthermore based on the actual numbers the savings rate and years till financial independence must be calculated. In order to calculate the years till financial independence there is something to assess my savings and investments.

The last years I didn’t spent too much attention to my spendings. Things were very stable and the savings rate was high enough to see progress. However now inflation is rising some more control might be needed. In the beginning of 2023 I want to have budgets in place to see if my spendings are still helping to get independent. However I have no clue now what amount is reasonable as a budget. I first need to get insight in my current spending to be able to set budgets. So let’s continue to categorize the transactions.

Using the domain the first time reveals imperfections

After importing all real transactions last blog. I finally got some insight. However I still had a large uncategorized amount. In my opinion the main reason for this is that the debit card transactions missed a contra account. I need to fix this imperfection. Probably another good way of categorizing these transactions is by the description. So let’s create a DescriptionRule that checks if the description contains some text. The code for this rule looks as follows:

public class DescriptionRule implements CategoryRule {
    private final Category category;
    private final String descriptionToMatch;

    public DescriptionRule(final Category category, 
            final String descriptionToMatch) {
        this.category = category;  
        this.descriptionToMatch = descriptionToMatch;
    }

    @Override  
    public boolean categorize(final Transaction transaction) {  
        if (transaction.description().contains(descriptionToMatch)) {  
            category.addTransactions(transaction);  
            return true;    
        }  
        return false;  
    }
}

And the resulting output:

Salary amount 1221,49
Coffee to go amount 0,00
Groceries amount -910,49
Energy amount -300,00
Taxes amount 650,00
UNCATEGORIZED amount -108,00
Electronics amount 0,00
Rent amount -400,00
Other amount 0,00

This works like a charm. Now I can categorize all debit card transactions. Most of them are groceries. After categorizing these transactions I notice in my real bank download that I have about 200 transactions left to categorize. And about 40% of them are less than 25 euro’s. In the fake download file there are 2 of such transactions uncategorized. In the grand scheme of things these don’t matter that much. So I want to create a rule that will be applied transactions to categorize all small transactions in the category other. Let’s create another rule:

public class AmountLessThanRule implements CategoryRule {

    private final Category category;
    private final Money amountToCompareTo;

    public AmountLessThanRule(final Category category, 
            final Money amountToCompareTo) {
        this.category = category;  
        this.amountToCompareTo = amountToCompareTo;
    }

    @Override  
    public boolean categorize(final Transaction transaction) {  
        if (amountToCompareTo.isGreaterThan(transaction.amount().abs()) {  
            category.addTransactions(transaction);  
            return true;    
        }  
        return false;  
    }
}

I make sure that this rule is applied last. This way all other rules can first categorize small transactions, leaving the catch all at the end. The outcome looks like this:

Salary amount 1221,49
Coffee to go amount 0,00
Groceries amount -910,49
Energy amount -300,00
Taxes amount 650,00
UNCATEGORIZED amount -100,00
Electronics amount 0,00
Rent amount -400,00
Other amount -8,00

Done, much better results and more insight where it matters 🎉.

Refactor the rules

When building the new rules something took my attention. All the rules have a categorize method and it is always looks like something shown below:

@Override
public boolean categorize(final Transaction transaction) {
  if (someFunction.appliedTo(transaction)) {
    category.addTransactions(transaction);
    return true;
  }
  return false;
}

Probably I should generalize this, simplifying the other code. Lets make a BaseRule I which I capture the essentials:

public class BaseRule implements CategoryRule {
  private final Category categoryToAssign;
  private final Function functionToApply;
  
  public BaseRule(final Category categoryToAssign, 
          final Function<Transaction, Boolean> functionToApply) {  
    this.categoryToAssign = categoryToAssign;  
    this.functionToApply = functionToApply;  
  }  

  @Override  
  public boolean categorize(final Transaction transaction) {  
    if (functionToApply.apply(transaction)) {  
      categoryToAssign.addTransactions(transaction);  
      return true;        
    }  
    return false;  
  }
}  

The implementing classes could be much smaller:

public class SmallerThanAmountRule extends BaseRule {
  public SmallerThanAmountRule(final Category category, 
          final Money amountToCompareTo) {
      super(category, (transaction ->       
          amountToCompareTo.isGreaterThan(transaction.amount().abs())));  
  }  
}
public class ContraAccountCatgegoryRule extends BaseRule {
  public ContraAccountCatgegoryRule(final Category category, 
        final String contraAccount) {  
    super(category, (transaction) -> 
        contraAccount.equals(transaction.contraAccountNumber()));  
  }  
}
public class DescriptionRule extends BaseRule {
  public DescriptionRule(final Category category, 
          final String descriptionToMatch)   {  
    super(category, (transaction) ->    
       transaction.description().contains(descriptionToMatch));  
  }  
}

Unittests are still green. Running the code delivers the same results. So great solution. It also allows to create new rules faster.

Lot’s of groceries

Let’s look at the results once again:

alary amount 1221,49
Coffee to go amount 0,00
Groceries amount -910,49
Energy amount -300,00
Taxes amount 650,00
UNCATEGORIZED amount -100,00
Electronics amount 0,00
Rent amount -400,00
Other amount -8,00

What am I spending ******* €900 in a little bit over a week. That does’t seem right. After sorting through some transactions I see the problem: I’m now categorizing all debit card transactions as groceries. This includes things that are definitely no groceries. Look at the transaction that causes problem:

Transaction{accountNumber='NL18RABO0123459876', date=2022-01-08, contraAccountNumber='', amount=-800,00, description='Pinnen  '}

Looking at the descriptions it is hard to identify strings that uniquely identify the groceries. So I check the raw import file to check if I can identify some fields that allow me to make the distinction. Ah there is a ‘Naam tegenpartij’ that roughly translates to name counter party. In this field typically the shop’s name is shown. Somehow I forgot to make this field part of the transaction to the domain. Let’s add it! The new domain diagram looks as follows:

Diagram showing the added field to Transaction, one of many actions to get financial insight.
Added the counterparty to the domain

After doing so I need another rule to categorize the transaction based on this field as well. Let’s call it the CounterPartyRule and it works similarly to the DescriptionRule. The code can be found here.

Let’s see how this works using the fake download file. I create some extra rules to categorize groceries and electronic expenditures:

private CategorizationRules createCategorizationRules() {
  CategorizationRules categorizationRules = new CategorizationRules(uncategorized);
  categorizationRules.add(new ContraAccountCatgegoryRule(salary, "NL98INGB0003856625"));
  categorizationRules.add(new ContraAccountCatgegoryRule(taxes, "NL98INGB0003856626"));
  categorizationRules.add(new ContraAccountCatgegoryRule(rent, "NL98INGB0003856627"));
  categorizationRules.add(new ContraAccountCatgegoryRule(energy, "NL98INGB0003856628"));
  categorizationRules.add(new CounterPartyCatgegoryRule(groceries, "counterparty"));
  categorizationRules.add(new CounterPartyCatgegoryRule(electronics, "electronics"));
  categorizationRules.add(new SmallerThanAmountRule(other, Money.of(25, EUR)));
  return categorizationRules;
}

After building this, the following output is created:

Salary amount 1221,49
Coffee to go amount 0,00
Groceries amount -210,49
Energy amount -300,00
Taxes amount 650,00
UNCATEGORIZED amount 0,00
Electronics amount -800,00
Rent amount -400,00
Other amount -8,00

Still a lot of groceries, but this is de actual amount spent.

Getting financial insight

I have created more types of rules and refactored these rules. Fixed the Transaction in the domain. After all this coding I have better financial insight. I know where I spent my money. My main learning is that you can design a domain to the best of your ability, however using it will really expose issues

As mentioned previously still the rules are kinda bolted on and are not yet part of the domain. As the end of the year is approaching rapidly, I still don’t have any budgets. Those are topics for future post.

You can find the code at GitHub.