I made a small application to automatically categorise my expenses and keep me on top of my finances.

The Problem
I wanted an easy way to categorise my expenses, so I could quickly compare my actual spending with my budget.
The workflow I wanted was:
- I manually download transaction data from my bank.
- ????
- I receive a spreadsheet with the same transaction data categorised and summarised
My bank already applies its own categorisations online, but it’s unreliable and doesn’t map to the categories I wanted.
The Solution
A spreadsheet and a few formulas would have sufficed.
Instead, I’ve completely over-engineered a solution as a chance to practice my dev skills. I’ve implemented domain models to validate the raw transaction data from the bank export, save it to a sqlite database, and categorise them according to rules I define through a CLI interface.
It revolves around allowing us to create different kinds of CategorisationRule, which can automatically determine each transaction’s category from its description and amount. For example:
- Description includes ’Anytime Fitness’ -> categorise as Health/Fitness
- External transfers exceeding $5k -> categorise as Investment/Transfers
- Multiple rules match: highest priority rule applies
How it works
First thing I do is save all my raw bank data to a folder:

I then run my application, and create a new categorisation rule for any unknown expenses:

Any rules I create are automatically applied to the remaining transactions on my list. Over time, the number of new rules I need to create decreases.
Each CategorisationRule I create looks like this:
class CategorisationRule(BaseModel, table=True):
"""Rule for categorising a transaction based on description and/or amount."""
id: int | None = Field(primary_key=True, default=None)
user_id: int | None = Field(foreign_key="user.id", default=None, ondelete="CASCADE")
priority: int = 0
description_contains: str | None = None
amount_min: float | None = None
amount_max: float | None = None
transaction_id: int | None = None
category_id: int | None = Field(foreign_key="category.id", ondelete="SET NULL")
subcategory_id: int | None = Field(
foreign_key="subcategory.id", ondelete="SET NULL"
) Features to highlight:
- Rules are user-specific. Any rule created is applied to transactions from all of their bank accounts.
- Rules have priorities. If one transaction matches multiple rules, we can control which one takes precedence.
- Transactions below a certain amount are ignored. Coffees won’t move the needle, so I needn’t waste my time categorising them.
Once transactions are categorised, we save the categorised data into our transaction database and print the results for whatever resolution or time period we like:

I can then visualise the data in Excel and share the results with my partner.
Next steps
This was a good chance to work on my skills in data modelling and managing database transactions safely with SQLAlchemy.
It’s a bit of a pain to use when I want to make changes though… currently, I need to override any mistakes by making manual edits to my database. A simple frontend to make these edits and print the results would make this much nicer to use.