My SO, Kim, and I share expenses for things like groceries, rent, car maintenance, and meals out. Up to now, we’ve been using a spreadsheet I found on moneyunder30.com. We’ve started to outgrow it, so I decided to roll my own solution using ledger-cli and a custom, email-based interface.

The spreadsheet was falling short in a couple ways. It wasn’t easy to capture transaction detail. We could put it into the comments on a given cell, but it wasn’t convenient and we didn’t use it consistently. I also needed to determine how to create what we called “pass-through” transactions. These represented instances when Kim or I would purchase something on the other’s behalf. Unlike our shared expenses, we each needed to pay the other back for the full amount of the purchase. I created a place for this on the spreadsheet, but it wasn’t very transparent.

Using a more sophisticated solution should resolve these issues. The path forward was pretty clear:

  1. Learn the basics of accounting
  2. Learn ledger-cli
  3. Build an email interface to ledger

Learning Accounting

Historically, I’ve had a hard time with the concept of double-entry bookkeeping. So, I knew I would need help to wrap my head around the concept. I ran a quick search on YouTube and found a series of videos on accounting basics by Accounting Stuff. In the channel’s “about” section, channel host James writes,

I created this channel in 2018 in order to share the knowledge and experience that I have built up over the past 6+ years working as a Qualified Accountant at the Big 4 and in Industry - at large Listed and Private Companies.

The channel had a whole playlist dedicated to accounting basics; I jumped into it immediately. I found his videos very easy to consume, and I learned all the pieces I had been missing. As I watched James' videos, I worked out a number of test scenarios on paper.

One concept I struggled with was defining the entity to which my journal entries belonged. I was trying to track expenses for Kim and myself, so is “Kim and myself” the entity? That was a bit tricky to think about, so I picked an entity that, at first, would have no existing financial relationship to Kim or myself – our cat, Abner.

Abner was going to start a business. The purpose of the business would be to purchase things that Kim and I needed using investment we provided. Each purchase would be represented by a transfer of equity from Kim or me to Abner’s cash account, and then a transfer from cash to the expense account. As purchases were made, the Expense account would be balanced by equity accounts assigned to Kim or me.

At the end of the month, Abner’s income would be calculated by subtracting expenses from revenue. Since Abner doesn’t have any way to generate revenue, he always operates at a loss. Abner’s negative income would then be distributed back to Kim’s and my equity accounts. These steps constitute the period-end close process. The balances of our equity accounts would indicate how much money needed to be transferred to even things out.

Learning Ledger CLI

So far, I had been doing all of my exercises on paper, but I needed to move to a computer-based solution. ledger-cli has been on my radar for a while, and I was excited to dig into it. From the ledger-cli page,

Ledger is a powerful, double-entry accounting system that is accessed from the UNIX command-line. Ledger, begun in 2003, is written by John Wiegley and released under the BSD license.

Having learned the basics of double-entry bookkeeping, learning ledger was pretty straight-forward. It uses a plain-text data format that is easy to manage by hand with an editor. Ledger doesn’t categorize accounts as normal debit accounts or normal credit accounts. I was glad that I learned about account types from Accounting Stuff so that I could apply these rules myself.

I created a test file and ran a few scenarios using the ledger reporting tool. Soon, I was replacing my test data with real data from the spreadsheet for the previous month. I created a month-end process to capture the expense totals for a target month, close expenses to income, and distribute the negative income back to our equity accounts. My month-end close process produced the same results as our spreadsheet.

To handle our pass-through transactions, I considered using a combination of A/R and A/P accounts. I decided to simplify things by representing it as a transfer from one equity account to another.

Building an Email Interface

To make it easy for Kim and me to interact with the ledger, adding transactions and pulling reports, I created an email-based user interface. We would need to be able to add transactions and pull balance and register reports.

Setting up an email server is pretty complicated and I’m not going to cover it here. In short, I set up Postfix to receive email at a given domain – let’s call it example.com. When Postfix receives an email, it needs to decide what to do with it. It looks at a file, /etc/aliases, for information on what to do with a message based on who it’s been sent to. In most cases, it will copy the message to the mailbox of the intended recipient on the system.

However, it’s possible to specify that a program should process the message, instead. This is how mailing lists and those email address verification tools work. The content of the message contains data that the program will use to complete its task. I’ve written about this before, in Email as an Application Interface.

In my case, there were three steps in processing an incoming email message and providing a response to the sender. First, I needed to trim off all the headers and other content that came with the email, but that I didn’t need. Second, I needed to parse the payload and update files or run ledger reports and capture the output. Finally, I needed to get the output back to the sender by email.

To better encapsulate related functionality, I updated my email parsing script so that it could be used in a pipeline and output a consistent format. I called it mail_trim and it lives in /usr/local/bin where Postfix can get to it.

#!/usr/bin/awk -f
BEGIN {
  boundary_seen = 0 
  data_seen = 0
  boundary = "^$"
  line = ""
}
# If we've started reading data and we hit a blank line, exit
(data_seen == 1) && ($0 ~ /^$/) {
  exit
}
# We've seen the boundary and can start collecting data
(boundary_seen == 1) && ($0 !~ /^$/) {
  line = line $0 "\n"
  data_seen = 1
  # reset the data gathering if we see this. Fragile.
  if (line ~ "text/plain" ) {
    line = ""
    data_seen = 0
  }
}
/^From:/ {
  $1 = ""; from = $0 
  sub(/^.*</,"",from)
  sub(/>.*$/,"",from)
}
$0 ~ boundary {
  boundary_seen = 1 
}
END {
  printf "%s\n%s", from, line
}

From mail_trim, the payload is sent to my new, ledger-compatible expenses script. This script consumes the payload and runs reports or adds transactions.

#!/usr/bin/awk -f
# take output from mail_trim and act on contents
BEGIN{
  expfile = "/path/to/expenses.txt"
  from = ""
  qw = "\047"
  date = strftime("%Y-%m-%d")
  bash = "/bin/bash"
  msmtp = "msmtp -t"
}
# mail_trim puts the from address in the first line.
NR == 1 {
  from = $0;
  if (from !~ /..*@..*/) exit;
  next;
}
# we can process multiple requests
/^[Bb]alance/ {
  cmd = "ledger -p " qw "this month" qw " -f " expfile " --balance-format " qw "%A\\t%(display_total)\\n" qw " balance"
  report = "";
  while ((cmd | getline ln) >0 ){ report = report ln "\n"}
  close(cmd)
  print "To: " from | msmtp
  print "Subject: Balance" | msmtp
  print "" | msmtp
  print report | msmtp
  close(msmtp);
  next;
}
/^[Rr]egister/ {
  cmd = "ledger -p " qw "this month" qw " -f " expfile " --register-format \"%D\\t%t\\t%A\\t%P\\n\" register Kim Dave"
  report = "";
  while ((cmd | getline ln) >0 ){ report = report ln "\n"}
  close(cmd)
  print "To: " from | msmtp
  print "Subject: Balance" | msmtp
  print "" | msmtp
  print report | msmtp
  close(msmtp);
  next;
}
/^[0-9]/ {
  amt = $1
  payee = $0
  sub(/\S+\s+/,"",payee);
  if (from ~ /Kim/) {
    acct = "Equity:Kim"
  } else {
    acct = "Equity:Dave"
  }
  printf "\n%s %s\n %s  %s\n %s\n", date, payee, "Expenses", amt, acct >> expfile
  next;
}
/^[Pp] *[0-9]/ {
  amt = $1
  sub(/^[Pp] */,"",amt);
  payee = $0
  sub(/\S+\s+/,"",payee);
  if (from ~ /Kim/) {
    cacct = "Equity:Kim"
    dacct = "Equity:Dave"
  } else {
    cacct = "Equity:Dave"
    dacct = "Equity:Kim"
  }
  printf "\n%s %s\n %s  %s\n %s\n", date, payee, dacct, amt, cacct >> expfile
  next;
}

I ran into an issue where some characters in the text/plain portion of the email were being encoded. This caused transactions in my expense file to have invalid data. After a little searching, I found that this was a transport encoding known as quoted-printable, or QP. At first, I was concerned that I would have to create my own decoder, but I found a simple decoder, qprint, in the Debian package collection. After adding that to the pipeline in /etc/aliases, transactions came in perfectly. The entry in /etc/aliases now looks like this:

expenses: "|/usr/local/bin/mail_trim |/usr/bin/qprint -d -n |/usr/local/bin/expense"

The default output format for ledger targets a display using a fixed-width font. Because these reports would be viewed in an email client using a proportional-width font, I had to develop custom output formats. You’ll see them as arguments to the --balance-format and --register-format options.

I’m happy that I’ve finally been able to make use of ledger-cli, and I’m glad that I took the time to learn the basics of accounting from Accounting Stuff. Now I have a solution that is more convenient and can be extended in the future.