2024-12-01 Automatically inserting Ledger transactions

I wrote a few times that I use and like Ledger a lot. As some of you might know, I even wrote a booklet about personal accounting, using Ledger in examples. One of the nice things about Ledger is that it comes with an Emacs mode to edit its files (which is not a surprise, given that it is written by John Wiegley himself). That is not to say, though, that it suits my needs perfectly.

Most of the transactions I enter in my Ledger file are purchases. They almost always have the same structure – one or more categories of expenses followed by the source, which is almost always some cash account (like Assets:Cash:Wallet:Me) or a payment card (like Assets:Bank:Wife). Ledger mode provides rudimentary support for inserting a transaction based on history (try C-c C-a and C-c <tab>, that is, ledger-add-transaction and ledger-fully-complete-xact, respectively), but I wanted something even more automatic. Of course, this is Emacs, so coding something like this should be a breeze.

I started with skimming the Ledger mode manual and sources to make sure I don’t reinvent a wheel. A bit surprisingly, I didn’t find a ready-made command to insert a transaction, so I decided that I’ll just need a bunch of insert​s and a call to ledger-post-align-xact.

I strive to enter my Ledger transactions on the day they were made, but I don’t always succeed, so my inserting command should first ask the user the date (defaulting to today). The next thing is the description, which should obviously have history and completions based on previously entered descriptions. I’m going to use persist-defvar from the persist package for that so that the history is remembered across Emacs sessions. (Note that I specifically do not want to use all the description from my Ledger file as autocompletion candidates – the file is over 8 years old, and I often don’t want to mimic transactions from many years ago.) Next comes the source – I could also use a persisted history for that, but since the possible source accounts are usually very limited (in my case, there are basically three of them possible – my wallet, my wife’s wallet, and my wife’s bank card, since I don’t use any), I decided to go with read-char-choice. This approach requires configuring the source accounts in the init file, but this is something done once, so it’s not a big problem. Finally, the user needs to provide the amount. Here I implemented a simple trick so that I won’t have to type the decimal point – if the amount is an integer greater than 100, it is treated as the number of cents (or grosze in my case) and divided by 100. This lets me save one keystroke.

The code is pretty simple. I thought about making this command a bit more versatile and allowing for non-interactive use (via the trick I wrote about almost a decade ago), but ultimately decided against it – the only purpose of my command is to allow entering transations quickly and interactively, so I figured that the added complexity is not worth it.

(require 'persist)

(persist-defvar mbork-ledger-descriptions ()
 "Alist of transaction descriptions for `mbork-ledger-insert-transaction'.
Each element is a cons where the car is the transaction description and
the cdr is the default target account.")

(defcustom mbork-ledger-source-accounts
  '((?c . "Assets:Cash")
    (?b . "Assets:Bank")
    (?d . "Liabilities:Card"))
  "Alist of source accounts for `mbork-ledger-insert-transaction'.
Each element is a cons where the car is the character used to select the
account and the cdr is the account name.")

(defcustom mbork-ledger-default-commodity "PLN"
  "The default commodity to use with `mbork-ledger-insert-transaction'.")

(defun mbork-ledger-insert-transaction ()
  "Quickly insert a Ledger transation.
Ask about the date, description, source and amount.  If the amount
entered is an integer greater than 100, divide it by 100, so that you
can enter e.g. 12.34 USD as `1234' for faster typing."
  (interactive)
  (let* ((date (ledger-read-date "Transation: "))
         (date-encoded
          (when (string-match ledger-iso-date-regexp date)
            (encode-time 0 0 0 (string-to-number (match-string 4 date))
                         (string-to-number (match-string 3 date))
                         (string-to-number (match-string 2 date)))))
         (description (completing-read "Description: "
                                       mbork-ledger-descriptions
                                       nil nil nil
                                       #'mbork-ledger-descriptions))
         (source (alist-get (read-char-choice
                             (concat (mapconcat (lambda (char)
                                                  (format "%c: %s" (car char) (cdr char)))
                                                mbork-ledger-source-accounts
                                                "\n")
                                     "\n")
                             (mapcar #'car mbork-ledger-source-accounts))
                            mbork-ledger-source-accounts))
         (amount (let ((input (read-number "Amount: ")))
                   (if (and (integerp input)
                            (> input 100))
                       (/ input 100.0)
                     input))))
    (ledger-xact-find-slot date-encoded)
    (insert (format "%s %s\n    "
                    date description))
    (save-excursion
      (insert (format "\n    * %s  -%.2f %s\n\n"
                      source amount mbork-ledger-default-commodity)))))

That’s it for today, see you next time!

CategoryEnglish, CategoryBlog, CategoryEmacs, CategoryLedger