In my last post I wrote about why TrackMyRupee doesn’t read your SMS or link to your bank account. This one picks up right after that decision, with the first real crack that showed up in the data model once I started using the app myself every day.
The first version of TrackMyRupee had exactly two tables that mattered: Expense and Income. That felt complete. Every rupee either came in or went out, so what else could there possibly be?
Within a couple of weeks of actually tracking my own money in it, that model started falling apart.
Say you transfer 10,000 rupees from your salary account to a savings account. Or you pay off your credit card bill from your bank account. Or you move money into a fixed deposit. None of these are expenses, you haven’t spent anything, the money is still yours, just sitting somewhere else. And none of them are income either, since nothing new came into your net worth.
I only had two buckets to put things in, so I had to force these somewhere, and both options were wrong:
Both paths led to the same place: the moment I had more than one account, expenses and income alone couldn’t represent reality anymore.
The fix wasn’t clever, but it was necessary. Cash flow needs three kinds of events, not two:
The real difference between a transfer and the other two is that a transfer needs two accounts, not one. An expense just needs “which account did this come out of.” A transfer needs “which account did this come out of, and which account did it land in.”
Here’s a simplified version of what the three models actually look like:
class Expense(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=15, decimal_places=2)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
date = models.DateField()
class Income(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=15, decimal_places=2)
source = models.CharField(max_length=255)
date = models.DateField()
class Transfer(models.Model):
from_account = models.ForeignKey(Account, related_name="transfers_out", on_delete=models.CASCADE)
to_account = models.ForeignKey(Account, related_name="transfers_in", on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=15, decimal_places=2)
date = models.DateField()
Once Transfer existed as its own thing, the rules for how it should affect the numbers became simple and consistent:
from_account and increase the balance of to_account by the exact same amountThere was a simpler option on the table: don’t add a new table at all, just log a transfer as a paired Expense and Income. One row of type Expense in an “Internal Transfer” category on the source account, and one row of type Income from an “Internal Transfer” source on the destination account.
This is less code, and it reuses tables that already exist. I still didn’t go with it, for a few reasons:
Modeling transfers as their own concept costs one more table and one more form. But it means total spent and total earned are always correct by construction, instead of correct only as long as every downstream query remembers to filter out a magic category.
Transfers turned out to be the easy version of this lesson. The harder version showed up once accounts themselves needed to track exact balances instead of just a rough log of ins and outs, and that’s where the real trouble started.
All content is licensed under the CC BY-SA 4.0 License unless otherwise specified