Project: Accounting Library
For this project, we will be building the foundation for a double-entry
accounting system called almighty-kaikei. Double-entry accounting is a system
that is designed to prevent or detect accounting errors by tracking two sides of
a ledger: the debit and credit sides.
There are five basic types of accounts in an double-entry accounting system: Asset, Expense, Income, Liability, and Equity. All of the accounts are recorded in what's called a Chart of Accounts.
The types are divided into two groups: left hand side (LHS) and right hand side (RHS) accounts. Assets and Expenses are LHS account, the rest are RHS accounts.
The balance of an account's ledger is calculated based on all of its debits and credits. For example, for a Sales Revenue account:
| Date | Description | Debit | Credit | Balance |
|---|---|---|---|---|
| 2025-11-4 | Opening Balance | 0 | ||
| 2025-11-5 | Cash Sale | 100 | 100 | |
| 2025-11-5 | Cash Sale | 50 | 150 | |
| 2025-11-6 | Return | 20 | 130 | |
| 2025-11-7 | Cash Sale | 200 | 330 | |
| --------- | --------------- | ----- | ------ | ------- |
| Total | 20 | 350 | 330 |
On the other hand, the Cost of Goods Sold (COGS) account for these transactions will look like this:
| Date | Description | Debit | Credit | Balance |
|---|---|---|---|---|
| 2025-11-4 | Opening Balance | 0 | ||
| 2025-11-5 | Cash Sale | 60 | 60 | |
| 2025-11-5 | Cash Sale | 20 | 80 | |
| 2025-11-6 | Return | 10 | 70 | |
| 2025-11-7 | Cash Sale | 150 | 220 | |
| --------- | --------------- | ----- | ------ | ------- |
| Total | 230 | 10 | 220 |
Notice that the value of the balance for the Sales (income/revenue) account increases with credits, but for the COGS (an asset account) the balance increases with debits.
For LHS accounts, credits increase the balance, debits decrease the balance. For RHS accounts, debits increase the balance, credits decrease the balance.
Thus,
- For LHS accounts,
(= balance (- debits credits)). - For RHS accounts,
(= balance (- credits debits)).
The balance of a transaction is calculated from the debits and credits on two or more accounts in the transaction. A transaction consists of a least one debit and one credit to two separate accounts.
Let's say we are a business that wants to record a cash sale of physical merchandise. A sale will affect that following accounts:
- Inventory (asset)
- Cash (asset)
- Sales Revenue (income)
- Cost of Goods Sold (COGS, expense)
Typically, transactions are visualized as a table with a debit and credit column:
| Account | Debit | Credit |
|---|---|---|
| Cash | 100 | |
| Sales Revenue | 100 | |
| COGS | 60 | |
| Inventory | 60 | |
| ------------- | ----- | ------ |
| Totals | 160 | 160 |
So in the table above, cash is debited (increasing in value), sales revenue (an income account) is credited (increasing in value). You now have recorded an increase in your cash-on-hand and your sales.
The Cash of Goods Sold account is debited (increasing in value), and your inventory is credited (decreasing in value). You have paid down the expense you had when purchasing an inventory good and the size or value of your inventory has decreased. By subtracting COGS from Sales Revenue, you can also know how much money you made from the transaction.
And the debit and credit totals match on both sides.
Ledgers are ways of viewing the data your system records. For example, a general ledger will show raw data: what transactions occurred. A sales ledger will show sales. An account ledger will show the transactions for an account and its current balance. An accounts payable aging report will show how much money you owe, to whom, and how overdue you might be on a certain account. All of these different ledgers provide insight into your accounting: cash flow, sales/returns, etc.
Requirements
Given the above, we need to provide the following functionality in our library:
- Account Creation
- Balanced Transaction Creation
- Account Balance Retrieval
- Chart of Accounts Balance Retrieval
The library should also provide the ability to easily create ledgers from the data it provides.
Testing & Examples
This library's functionality is going to be trickier than the almighty-money
library's was, so we're going to need to create tests. For that, we'll be using
lisp-unit2. We will do some basic unit tests, and we'll make some example
ledgers to demonstrate the library's use in an accounting system.
Dependencies
We will be using the almighty-money library we created along with some other
dependencies.
Since we are now adding dependencies to our project, I will introduce you to
vend, a library that replaces the defacto-standard quicklisp library for
dependency management. Let me explain why.
Vendor Your Dependencies
The guiding principle of the Almighty Lisp developer is to, above all else, prioritize simplicity. Simplicity is the key to writing code that you and others can maintain without fear or frustration.
When it comes to dependencies, simplicity looks like this:
- Generally avoid adding dependencies to a project unless necessary. If you can write a simple thing yourself that gets the job done, you probably should.
- When adding dependencies to your project, adopt them as your code. You are now responsible for their bugs, features, efficiency, etc.
- When possible, remove dependencies. Usually this means slowly extracting functionality from a dependency and rewriting it customized for your needs.
Some people feel that the trend to make dependencies easier to add to a
project via tools like npm or uv are detrimental to codebase quality and are
a security liability. If adding and maintaining dependencies with such tools is
easy, you will be tempted to add and keep large amounts of third-party code in
your codebase. Adding a dependency is and should feel like a serious, risky
decision.
While I can see the benefits of that mindset, the Almighty Lisp philosophy is one that is designed to maximize the effectiveness of the individual programmer. If you have a team of developers, you can expect to be able to work together to write the exact code that you need, both your library code and application code. Individual programmers need to prioritize writing application code over library code, which means individuals need to rely more heavily on dependencies.
If your brain is so big you can crank out the whole web stack by yourself while producing high-quality code, you can ignore the above. For the rest of us, we need a simple way of introducing dependencies into our projects that provide us the means to easily transition dependencies into code fit exactly to our purpose, free from the potential of introduced security vulnerabilities.
vend is a library that does precisely that by making it simple to vendor our
dependencies. Vend works by looking at our defsystem definitions, looks at
what other systems our system :depends-on, and cloning the requisite systems
from github into a directory in our project folder–defaulting to vendored.
If for some reason you don't want to use vend, you can feel free to use
quicklisp instead.
Installing vend
Installing vend is simple, but it does have one unusual requirement: it
requires ECL, a different implementation of Common Lisp (we installed SBCL at
the very beginning, before even installing Emacs). So first, we need to install
ECL.
If you're on Linux, you can try looking for a package for your distribution.
On MacOS, ECL is available on Homebrew.
Otherwise, you can follow the instructions below to compile ECL from the source. These come directly from the manual.
Download ECL
Download the latest release of ECL from their server.
Extract the source code and enter its directory
$ tar -xf ecl-xx.x.x.tgz
$ cd ecl-xx.x.x
Run the configuration file, build the program and install it
$ ./configure --prefix=/usr/local
$ make # -jX if you have X cores
$ make install
Make sure the program is installed and ready to run
$ /usr/local/bin/ecl
ECL (Embeddable Common-Lisp) 16.0.0
Copyright (C) 1984 Taiichi Yuasa and Masami Hagiya
Copyright (C) 1993 Giuseppe Attardi
Copyright (C) 2000 Juan J. Garcia-Ripoll
Copyright (C) 2015 Daniel Kochmanski
ECL is free software, and you are welcome to redistribute it
under certain conditions; see file 'Copyright' for details.
Type :h for Help.
Top level in: #<process TOP-LEVEL>.
>
Clone vend git repo
Now that you have ECL installed, you can install vend.
Clone the vend git repository from Github.
Run make
After cloning the repo, navigate into the vend directory and run the following commands:
make
make install
After that, enter vend in the command line (you may need to restart the
terminal) and you should see this:
vend - Vendor your Common Lisp dependencies
Commands:
check [focus] - Check your dependencies for issues
get - Download all project dependencies into 'vendored/'
graph [focus] - Visualise a graph of transitive project dependencies
init [name] - Create a minimal project skeleton
repl [args] - Start a Lisp session with only your vendored ASDF systems
search [term] - Search known systems
test [args] - Run all detected test systems
Flags:
--help - Display this help message
--version - Display the current version of vend
Emacs configuration
You need to configure Emacs to open the REPL via vend. To open your
config.el file, type C-h d c or SPC f P (doom/open-private-config) and
open config.el from the minibuffer. In your config.el file, add:
(setq sly-default-lisp 'sbcl
sly-lisp-implementations '((sbcl ("vend" "repl" "sbcl") :coding-system
utf-8-unix)
(ecl ("vend" "repl" "ecl") :coding-system
utf-8-unix)
(abcl ("vend" "repl" "abcl") :coding-system
utf-8-unix)
(clasp ("vend" "repl" "clasp") :coding-system
utf-8-unix)))
Can't find vend?
I had this error while testing on a fresh install of Ubuntu:
File local-variables error: (doom-hook-error lisp-mode-local-vars-hook
sly-editing-mode (file-missing Searching for program No such file or directory
vend))
That means that Emacs can't find the vend executable. To resolve the error, I
installed the package exec-path-from-shell.
To install it, type SPC f P and open config.el. Add this to your config.el
file:
(use-package! exec-path-from-shell
:when (or (memq window-system '(mac ns x))
(daemonp))
:config
(exec-path-from-shell-initialize))
And add this to your packages.el file (in the same folder as the config.el):
(package! exec-path-from-shell)
Run doom sync in the terminal and restart doom with SPC q r
(doom/restart-and-restore).
Strangely, after I did this and then undid the changes, vend continued to
work. We all love software that just works, don't we folks?
Project Setup
Our project is called almighty-kaikei, so find-file (C-x C-f or SPC f f)
almighty-kaikei/almighty-kaikei.asd and save it. We're going to need tests
later, so just make a t directory, too.
Now that we have vend installed, we can get the rest of our dependencies.
First, we create a directory called almighty-kaikei and define our system in
almighty-kaikei.asd.
(defsystem "almighty-kaikei"
:author "Micah Killian"
:version "0.0.1"
:description "Almighty Double-Entry Accounting Program"
:depends-on (#:almighty-money #:local-time #:mito #:sxql #:dbi)
:components ((:file "src/main")))
Make sure to save the file.
Now it's time to run vend. In the terminal, run this command:
% vend get
You will get an error saying that almighty-money isn't a known system. Not
surprising–it's on our computer!
To get around this, first we make the vendored directory ourselves and make
another directory inside it called almighty-money.
% mkdir vendored
% cd vendored
% mkdir almighty-money
Now we can make a symbolic link from where we saved almighty-money and the
directory we just made.
% ln -s ~/path/to/the/original/almighty-money almighty-money
If you did everything right, you can run vend get in the almighty-kaikei
directory. It will find the almighty-money.asd file and won't attempt to
search for or download it. Then it will download the rest of the dependencies as
expected.
Now that we have our system setup, let's create the main.lisp file in the
project root and define the package.
(defpackage #:almighty-kaikei
(:use #:cl)
(:nicknames #:almighty-kaikei/main #:ak)
(:local-nicknames (#:lt #:local-time)
(#:m #:mito)
(#:s #:sxql)
(#:d #:dbi)
(#:am #:almighty-money)))
Now's a good time to learn a bit about our dependencies.
Introducing Our Dependencies
local-time is the defacto standard library for working with time, similar to Python's
datetime library.
mito is a SQL object-relational mapper. It's not such an opinionated ORM–we can
rely on simple SQL queries for the most part when using it. It does the work of
creating database tables and serializing between Lisp and SQL data.
sxql is a domain specific language for writing SQL in Lisp. I happen to really
enjoy doing everything in Lisp (in a later project we will write HTML with the
hsx library, another DSL). Putting everything I'm doing right in one spot,
rather than spreading it across several files, just feels right to me.
If you're not such an enthusiast of this style, you can check out the cl-yesql
library. It is a library similar to Clojure's Yesql library. It allows you to
use SQL from Lisp, rather than write it as with sxql.
dbi is a library for interfacing with databases. It provides functionality like
SQL transactions, preparing and executing queries, etc. We need this to connect
to our database and one query.
Why SQL?
You might be wondering why we're using SQL instead of straight Lisp. The answer is that we want to be able to use this library as is in a production environment where we expect to be using a SQL database.
Accounts
The first thing we need to do is provide a way of designating and knowing whether an account is a left-hand-side account or a right-hand-side account.
Recall that there are five basic categories of account: asset, expense, income, liability, and equity. Recall also that debits and credits influence the value of LHS and RHS differently and use a different formula for calculating their balance. So when we make accounts, we are going to assign it a certain category and we'll need to know which side it is in order to calculate its balance.
(defparameter *left-hand-side* '(("asset" -1) ("expense" -1)))
(defparameter *right-hand-side* '(("income" 1) ("liability" 1) ("equity" 1)))
(defparameter *account-types* (append *left-hand-side* *right-hand-side*))
(defun valid-account-category-p (account-category)
"A predicate that takes an account category string. Returns a list of the
category and its side, or nil."
(assoc account-category *account-types* :test #'equal))
Just a reminder: the :test #'equal parameter in the call to assoc is
necessary because strings are arrays of characters that are instantiated
separately. Since the default for :test is #'eql, member usually tests if
two things are in identical places in memory. equal, on the other hand, will
compare the structure of its inputs.
(eql "hello" "hello") ;; two separately instatiated arrays
; => NIL
(let ((str "hello"))
(eql str str)) ;; same variable, same place in memory
; => T
(equal "hello" "hello")
; => T
Next, we define the account table with Mito:
(m:deftable account ()
((name :col-type :text)
(code :col-type :text)
(category :col-type :text)
(currency :col-type :text))
(:table-name "almighty_account")
(:documentation "A mito macro that defines a class and SQL table for
double-entry accounting accounts."))
(defmethod print-object ((this account) stream)
(print-unreadable-object (this stream :type t :identity t)
(with-slots (name code) this
(format stream "~a ~a" name code))))
(defun side (account)
"A function for getting the multiplier used for determining whether a
debit/credit increases or decreases the value of an account."
(second (assoc (account-category account) *account-types* :test #'equal)))
Mito's deftable macro expand into a defclass with a special :meta-class.
The slots of the class will all have accessors prefixed with the name of the
table. In this case, the accessor for the name slot is account-name. This is
similar to how structures work.
The :col-type slot option is for setting the SQL column type. In our case, we
are going to use SQLite, which only really has text and integer types. If we
were using PostgreSQL, we'd need to be more specific with our :col-types.
By default Mito will set the SQL table name to the name of the Lisp class
(account here), but we can manually set it with the :table-name class
option. Here we are namespacing it with the almighty_ prefix.
We also define a print-object method for the account class to make it easier
to understand when we print account objects to the REPL.
side will be important for when we enter and retrieve data from the database:
it will determine whether the amount entered or returned is negative or
positive.
We can manually construct account objects with make-instance, but we'll make
a custom constructor for convenience.
(defun make-account (name code category &optional (currency
am:*default-currency*))
"A constructor for ACCOUNT objects. CURRENCY defaults to
ALMIGHTY-MONEY:*DEFAULT-CURRENCY*."
(unless (valid-account-category-p category)
(error "~a is not a valid account category." category))
(make-instance 'account :name name :code code :category category :currency
currency))
That also allows us to validate the category.
Because we need to store and retrieve accounts to and from SQL tables, we need some functions for doing that for accounts.
(defun save-account (account)
"A function for saving ACCOUNT rows to a database. Returns an error if the
account already exists."
(let* ((code (account-code account))
(account-exists (m:select-dao 'account (s:where (:= :code (account-code
account))))))
(when account-exists
(error "An account with code ~a already exists." code))
(m:insert-dao account)))
(defun get-or-create-account (account)
"A function that takes an ACCOUNT object and searches your database for an
ACCOUNT with the same CODE. If one exists, it's returned. Eitherwise, it's
created and returned."
(let ((account-exists (m:select-dao 'account (s:where (:= :code (account-code
account))))))
(if account-exists
(first account-exists)
(m:insert-dao account))))
(defun get-account (account-code)
"A function that takes an ACCOUNT CODE and returns an ACCOUNT object. Returns
an error if an ACCOUNT with that CODE doesn't exist."
(let ((account (first (m:select-dao 'account
(s:where (:= :code account-code))))))
(unless account
(error "An account with code ~a doesn't exist." account-code))
account))
(m:define-accessor account-transactions (this account)
(m:select-dao 'transaction
(s:where (:= :account_id (object-id this)))))
save-account is self-explanatory: We don't want duplicate accounts, so we
prevent that with save-account by first doing a lookup for the account-code
of the account we're trying to create.
get-or-create-account is useful for times where you want to ensure an account
is saved in the database without causing an error if it already exists.
get-account makes it easy to get an account from a simple string containing an
account code.
define-accessor is a mito macro useful for reducing database hits for things
like one-to-many and many-to-many relationship queries. In this case, we have a
one-to-many query: getting all the transactions for an account object we pass to
it.
For all of these, we used mito and sxql to query our database. Mito provides
select-dao, a macro creating a list of Lisp objects containing the queried
data. The first argument is a symbol–the name of a table defined with
mito:deftable. It acts as the "SELECT * FROM [table-name]" part of a SQL
query. You then provide the rest of the query using sxql.
You'll notice that we refer to the transaction table in
account-transactions. We'll define that next.
Transactions
When a transaction is made, such as a sale, a return, a payment, etc. we need to create different parts (what we'll call Legs)–debits and credits–that we need a way to know which parts go together. Transactions timestamps and ids are doing to be how we refer to groups of Legs.
Transactions are simple to implement.
(m:deftable transaction ()
((date :col-type :timestamp
:inflate #'lt:universal-to-timestamp
:deflate #'lt:timestamp-to-universal))
(:table-name "almighty_transaction"))
(defmethod print-object ((this transaction) stream)
(print-unreadable-object (this stream :type t :identity t)
(with-slots (date) this
(format stream "~a" date))))
(defun make-transaction (&optional (date (lt:now)))
(make-instance 'transaction :date date))
Mito provides the ability to set how a slot "inflates" and
"deflates"–serializes into Lisp or SQL values. We are using local-time to
convert between universal times and local-time timestamps. Universal times are a
simple integer–the number of seconds after 2000-3-1. While it's not strictly
necessary to serialize to universal times before saving to the database, it will
make future queries easier.
make-transaction sets the default value of the date slot to
local-time:now.
Legs
The real fun begins with legs. Legs are like entries in the accounting books: This account was debited this amount at this time. A Transaction has to have at least two Legs–a debit and a credit–to be balanced. The whole purposes of a double-entry accounting system is to record at least two sides of a transaction to reduce errors in money tracking. If a transaction doesn't balance, that's a sign that there is an error in money tracking.
So first, let's setup our leg table.
(m:deftable leg ()
((account :col-type account)
(side :col-type :integer)
;; Use LEG-ENTRY to access ENTRY-AMOUNT and ENTRY-CURRENCY as an
;; ALMIGHTY-MONEY:MONEY object.
(entry-amount :col-type :integer) ; ALMIGHTY-MONEY:MONEY object AMOUNT.
(entry-currency :col-type :text) ; ALMIGHTY-MONEY:MONEY object CURRENCY-CODE.
(transaction :col-type (or transaction :null)))
(:table-name "almighty_leg"))
(defmethod print-object ((this leg) stream)
(print-unreadable-object (this stream :type t :identity t)
(let ((account (when (slot-boundp this 'account) (slot-value this 'account)))
(transaction (when (slot-boundp this 'transaction) (slot-value this
'transaction))))
(format stream "~a ~[DEBIT~;CREDIT~] ~a" account side (leg-entry this)))))
Mito allows us to assign :col-type to other tables, creating a relationship
between different tables. The SQL table will actually have account_id and
transaction_id columns corresponding to the ids of the other table rows.
side records whether a leg is a debit or a credit. Debits will be represented
with 0, and credits with 1. This makes the format string in print-object
easier: we use tilde brackets ~[...;...~] to do conditional printing. If the
value of slot is 0, then the first string (zero-indexed) will be printed. If
it's 1, then the string after the ~; will be printed. You can do this with an
arbitrary number of integer values.
(format nil "~[HELLO~;GOODBYE~;KONNICHIWA~;SAYONARA~]" 0)
; => "HELLO"
(format nil "~[HELLO~;GOODBYE~;KONNICHIWA~;SAYONARA~]" 3)
; => "SAYONARA"
entry-amount and entry-currency will record the amount of a certain currency
of the leg. Ideally, we would be able to define some kind of custom :col-type
for an almighty-money:money object and name it entry, but mito doesn't
provide the ability to create custom column types on the Lisp side, so we will
simulate it by creating an "accessor" for entries:
(defgeneric leg-entry (leg)
(:documentation "A function for getting the ENTRY-AMOUNT and ENTRY-CURRENCY of
a LEG object and returning a MONEY object.")
(:method ((this leg))
(am:make-money (leg-entry-amount this) (leg-entry-currency this))))
Since all class slot accessors are generic functions, I am implementing
leg-entry as a generic, too.
Now we need a constructor.
(defun make-leg (account side entry transaction)
"A constructor for making LEG objects. ACCOUNT can either be an ACCOUNT object
or an ACCOUNT-CODE. ENTRY must receive an ALMIGHTY-MONEY:MONEY object.
Usage example:
(make-leg \"CODE000\" (usd 5000))"
(let ((account (etypecase account
(account account)
(string (get-account account)))))
(unless (am:moneyp entry)
(error 'type-error :expected 'am:money :datum entry))
(make-instance 'leg :account account
:side side
:entry-amount (am:amount entry)
:entry-currency (am:currency-code entry)
:transaction transaction)))
We use etypecase to convert from one a string account-code to an account
object if necessary. We want almighty-money:money objects passed as the
entry, so we signal a type-error condition if we don't get a money object
in the entry position.
Usually we don't want to use make-leg directly. Instead, we'll provide some
helper functions for making debits and credits.
(defparameter *debit* -1)
(defparameter *credit* 1)
(defun debit (account amount)
"A function for constructing a LEG object with a side of *DEBIT*. The
TRANSACTION slot will be modified later before saving the row to the database.
Usage examples:
(debit \"CASH000\" (usd 5000))
(debit \"COGS000\" 10000)
"
(let ((account (etypecase account
(account account)
(string (get-account account))))
(amount (etypecase amount
(integer (am:make-money amount))
(am:money amount))))
(make-leg account *debit* (am:money* amount (side account) *debit*) nil)))
(defun credit (account amount)
"A function for constructing a LEG object with a side of *CREDIT*. The
TRANSACTION slot will be modified later before saving the row to the database.
Usage example:
(credit \"REV000\" (usd 5000))
(credit \"SLS000\" 10000)
"
(let ((account (etypecase account
(account account)
(string (get-account account))))
(amount (etypecase amount
(integer (am:make-money amount))
(am:money amount))))
(make-leg account *credit* (am:money* amount (side account) *credit*) nil)))
The amount can be either a almighty-money:money object, or a simple integer.
If it's an integer, then we convert it to a money object using the
almighty-money:*default-currency*. Unless you're dealing with multiple
currencies, you can simply set the *default-currency* for your application and
save yourself some typing.
Remember that almighty-money only supports USD and JPY at the moment. If
you want to use some other currency in almighty-kaikei, you need to add
support to almighty-money.
The important bit is the fact that we take the amount and multiply it by the
side of the account and by *debit* or *credit*. An asset account that is
credited should save a negative amount into the database–crediting a
left-hand-side account should decrease its value. On the other hand, crediting
a right-hand-side account should increase its value.
It might be clarifying to look at the math this way:
(let ((lhs -1)
(rhs 1)
(debit -1)
(credit 1)
(amount 7))
(list (* amount lhs debit)
(* amount lhs credit)
(* amount rhs debit)
(* amount rhs credit)))
; => (7 -7 -7 7)
We're getting close to being able to make a transaction. Before we get there, we need to be able to tell debit legs from credit legs so we can see if the two sides of the transaction are balanced.
(defun debit-p (leg)
(= (leg-side leg) *debit*))
(defun credit-p (leg)
(= (leg-side leg) *credit*))
(defun sides-balanced-p (legs)
(let ((debits (sum-all-if #'debit-p legs))
(credits (sum-all-if #'credit-p legs)))
(= (abs (am:amount debits)) (abs (am:amount credits)))))
Note that we are looking at the absolute value of the debit and credit amounts.
As mentioned before, crediting a left-hand-side account will result in a
negative value being passed to make-leg, the same is if you debited a
right-hand-side account like an income account. The effect is that we always are
going to have a positive and a negative value in our transactions. Both should
have the same absolute value, though.
Finally, we make the transact! function.
(define-condition unbalanced-transaction (error)
((debit-amount :initarg :debit-amount :reader debit-amount)
(credit-amount :initarg :credit-amount :reader credit-amount)
(message :initarg :message :reader message
:initform "Left and right entries of the transaction do not balance."))
(:report (lambda (condition stream)
(with-slots (debit-amount credit-amount message) condition
(format stream "~a LEFT: ~a | RIGHT: ~a" message debit-amount
credit-amount)))))
(defun record-transaction (transaction legs)
"A function for saving the data rows of a transaction to a SQL database:
Legs for the transaction, and the Transaction itself."
(let ((transaction (m:insert-dao transaction)))
(loop :for leg :in legs
:do (m:insert-dao leg))
transaction))
(defun transact! (&rest legs)
"A function for making a double-entry accounting transaction having at least
one debit and one credit leg.
If there is a LOCAL-TIME:TIMESTAMP in LEGS, the first one is used for the
transaction. If no LOCAL-TIME:TIMESTAMP is in LEGS, uses LOCAL-TIME:NOW for the
transaction.
Returns an UNBALANCED-TRANSACTION error if debits /= credits.
Usage Examples:
(transact! (debit \"CASH000\" 5000)
(credit \"REV000\" 5000))
(transact! (local-time:parse-timestring \"2025-12-25\")
(debit \"CASH000\" 5000)
(credit \"REV000\" 5000))
"
(let ((date (or (find-if (lambda (x) (typep x 'lt:timestamp)) legs) (lt:now)))
(legs (remove-if-not (lambda (x) (typep x 'leg)) legs)))
(unless (> (length legs) 1)
(error "transactions must have at least two legs."))
(unless (and (find-if #'debit-p legs)
(find-if #'credit-p legs))
(error "transactions must have at least one debit and one credit leg."))
(let ((transaction (make-transaction date))
(debits (sum-all-if #'debit-p legs))
(credits (sum-all-if #'credit-p legs)))
(unless (sides-balanced-p legs)
(error 'unbalanced-transaction
:debit-amount (am:amount debits)
:credit-amount (am:amount credits)))
(loop :for leg :in legs
:do (setf (leg-transaction leg) transaction))
(record-transaction transaction legs))))
If we look at transact! first, it can take an arbitrary number of legs. Since
legs is a &rest parameter, legs will be accessible in a list. First, we
check to see if any of the arguments are a local-time:timestamp. If we find
one, we will use that for the transaction.
After that, we're going to ensure first that we have at least two legs. We also
ensure that there is at least one debit and one credit in there. If the sides
are all balanced, we add a transaction to the transaction slot of all the
legs and save the transaction to the database.
If the sides are unbalanced, we signal an unbalanced-transaction error.
Creating and signaling a condition will make it easy to take specific actions
for that kind of error (in a web application, we'd like to send the error to the
user in the browser, for example).
Playing around
Before we continue, how about we try playing with the code. In order to actually create any data, first we need to connect to our database and create tables. For right now, let's just keep things simple by establishing a long-lasting connection at toplevel.
(mito:connect-toplevel :sqlite3 :database-name "t/test.db")
If you forgot to make a t directory for tests during project setup, you should
do that before running the above code.
Later, you can disconnect using disconnect-toplevel
(mito:disconnect-toplevel)
After establishing a connection, we need to create the tables. Mito provides a useful function for this purpose:
(m:ensure-table-exists 'account)
(m:ensure-table-exists 'transaction)
(m:ensure-table-exists 'leg)
ensure-table-exists will look to see if a table exists. If it doesn't, it
creates it. Otherwise, it returns nil.
Once you have your tables, you need some accounts to mess with.
(save-account (make-account "Cash" "CSH000" "asset"))
(save-account (make-account "Sales Revenue" "REV000" "income"))
We'll just make these for now.
Let's make a sale: customer pays cash for something.
(transact! (debit "CSH000" (usd 5000))
(credit "REV000" (usd 5000)))
; => #<TRANSACTION
; 2025-11-26T04:41:56.367255Z
; {700805E313}>
We can look at the contents of the database using Emacs' built-in sqlite-mode
for looking at SQLite databases. Type M-x sqlite-mode-open-file and open
t/test.db. You should see the names of the tables we made and the number of
rows in each table.
| Table Name | Number of Rows |
|---|---|
| almightyaccount | 2 |
| almightyleg | 2 |
| almightytransaction | 1 |
If you switch from Evil's Normal-mode to Emacs-mode, a mode that acts
exactly like if you weren't using Evil at all, then you can even press Enter
on a table name and see the rows and their data.
Account Balance
So we've confirmed that we're saving data to the database, but now we need to know something about the accounts: What are their current balances? For that, we need write another function.
(defun account-legs (account &optional (as-of (universal-now)))
"A function for getting all the legs of an account as of a certain time,
defaulting to now.
Usage Examples:
(account-legs \"CSH000\")
(account-legs sales-account-object transaction-object)
(account-legs cogs-account-object (local-time:parse-timestring
\"2025-11-27\"))
"
(let* ((account (etypecase account
(account account)
(string (get-account account))))
(as-of (etypecase as-of
(lt:timestamp (lt:timestamp-to-universal as-of))
(transaction (lt:timestamp-to-universal (transaction-date
as-of)))
(string (lt:timestamp-to-universal (lt:parse-timestring as-of)))
(integer as-of))))
(m:select-by-sql 'leg
(s:select :*
(s:from (:as :almighty_leg :l))
(s:inner-join (:as :almighty_transaction :t) :on (:=
:l.transaction_id :t.id))
(s:where (:and (:= :l.account_id (m:object-id account))
(:<= :t.date as-of)))))))
(defun account-balance (account &optional (as-of (universal-now)))
"A function for getting the balance of an account at a certain point in time,
defaulting to now.
Usage Example:
(account-balance \"CSH000\")
(account-balance sales-account-object)
"
(let* ((account (if (stringp account)
(get-account account)
account))
(legs (account-legs account as-of)))
(if legs
(am:money* (reduce #'am:money+ (mapcar #'leg-entry legs)) (side account))
(am:make-money 0 (account-currency account)))))
account-legs gets all the legs for an account, filtered by the date of the
transaction. account-balance sums up the debit and credit legs of an account
then subtracts debits from credits or credits from debits depending on the type
of account.
Long-term problems
Everything is chugging along quite nicely, but there's a problem. If you're
familiar with database query optimization, you probably already wrote a nasty
post about me on X when we made the account-balance function.
"You absolute clown, you're getting all of the leg rows for a certain account, then passing them to Lisp to do the summation of their debits and credits? Delete this book."
This is a reasonable position to have. With very few leg rows, this isn't really
such a big deal. However, as the number of leg rows grows, the slower the
operation will become. We can confirm that by making some mock data and then
running account-balance on it.
;; Create lots of mock data, saving to disk. If you already had some test data
;; of your own, you can delete the .db file before running this.
;;
;; This will take some time. Emacs will freeze while this process runs.
(with-coa
(let ((cash (ak:get-account "CSH000"))
(sale (ak:get-account "SLS000"))
(date (lt:now))
(times 100000)
(amount 500))
(dotimes (i times)
(ak:transact! (ak:debit cash (am:usd amount))
(ak:credit sale (am:usd amount))
date))))
;; Run ACCOUNT-BALANCE on mock data
(with-coa (ak:account-balance "CSH000"))
If you run account-balance now, there will be a noticable lag between
executing and receiving the return value.
Let's do some basic profiling:
(time (with-coa (ak:account-balance "CSH000")))
time is a function for basic, dirty profiling. It will show how long it takes
to complete a task along with some other information. If you run it in the REPL,
you should get a result that looks something like this:
Evaluation took:
2.944 seconds of real time
2.950970 seconds of total run time (2.826497 user, 0.124473 system)
[ Real times consist of 0.256 seconds GC time, and 2.688 seconds non-GC time. ]
[ Run times consist of 0.254 seconds GC time, and 2.697 seconds non-GC time. ]
100.24% CPU
7 forms interpreted
1,680,876,464 bytes consed
Well, what happens if we add a check for the account balance of the sales account?
(time (with-coa
(ak:account-balance-slow "CSH000")
(ak:account-balance-slow "SLS000")))
The result:
Evaluation took:
5.879 seconds of real time
5.890071 seconds of total run time (5.630371 user, 0.259700 system)
[ Real times consist of 0.511 seconds GC time, and 5.368 seconds non-GC time. ]
[ Run times consist of 0.509 seconds GC time, and 5.382 seconds non-GC time. ]
100.19% CPU
7 forms interpreted
3,361,540,272 bytes consed
As suspected, account-balance is going to be a problem in the long-term. We
need to optimize.
Optimization
We could try getting back less data in our account-legs function just as an
experiment.
(defun account-legs (account &optional (as-of (universal-now)))
"A function for getting all the legs of an account as of a certain time,
defaulting to now.
Usage Examples:
(account-legs \"CSH000\")
(account-legs sales-account-object transaction-object)
(account-legs cogs-account-object (local-time:parse-timestring \"2025-11-27\"))"
(let* ((account (etypecase account
(account account)
(string (get-account account))))
(as-of (etypecase as-of
(lt:timestamp (lt:timestamp-to-universal as-of))
(transaction (lt:timestamp-to-universal (transaction-date as-of)))
(string (lt:timestamp-to-universal (lt:parse-timestring as-of)))
(integer as-of))))
(m:select-by-sql 'leg
;; Only returning amount, currency, and side
(s:select (:entry_amount :entry_currency :side)
(s:from (:as :almighty_leg :l))
(s:inner-join (:as :almighty_transaction :t)
:on (:= :l.transaction_id :t.id))
(s:where (:and
(:= :l.account_id (m:object-id account))
(:<= :t.date as-of))))))
If we run our little test again (running account-balance twice) we get:
Evaluation took:
1.296 seconds of real time
1.300803 seconds of total run time (1.235469 user, 0.065334 system)
[ Real times consist of 0.112 seconds GC time, and 1.184 seconds non-GC time. ]
[ Run times consist of 0.112 seconds GC time, and 1.189 seconds non-GC time. ]
100.39% CPU
7 forms interpreted
782,634,128 bytes consed
This confirms that we are definitely being bogged down by all this object construction and deconstruction (notice the dramatic decrease in bytes consed), but focusing what data we get from the database isn't remotely enough to fix our problem.
This is a common problem when using ORMs: if we try to do serious calculations by first serializing all of the data into our backend, we will inevitably see performance drops.
What we really want is for our database to do all the work of marshaling the
data and summing up the amount column of all of the leg rows, and just
receive a single value–the balance! That way we aren't building up and breaking
down a bunch of Lisp objects just to do a little bit of math.
Making a Query With cl-dbi
This is where cl-dbi will be necessary. It will allow us to execute a query
manually.
dbi works by first preparing a query for a certain database connection.
(dbi:prepare database-connection query)
The query is a simple string. You can yield one from a SQL statement prepared
with sxql.
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5)))
; => #<SXQL-STATEMENT: SELECT * FROM
; almighty_account WHERE
; (almighty_account.id = 5)>
(sxql:yield
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5))))
; => "SELECT * FROM almighty_account
; WHERE (almighty_account.id = ?)", (5)
Notice that we receive two values from yield. This is to make it easy to use
dbi to prepare the statement–sanitizing user input to prevent SQL inject
attacks.
(with-chart-of-accounts
(multiple-value-bind (query binds)
(sxql:yield
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5))))
(dbi:prepare mito:*connection* query)))
; => #<DBD.SQLITE3:DBD-SQLITE3-QUERY
; {70078DE2C3}>
We pass the prepared query and binds to execute to get a result.
(with-chart-of-accounts
(multiple-value-bind (query binds)
(sxql:yield
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5))))
(dbi:execute (dbi:prepare mito:*connection* query)
binds)))
; => #<DBD.SQLITE3:DBD-SQLITE3-QUERY
; {70081DDED3}>
We have two options: we can take one row of the result at a time and do something with it, or we can receive the whole thing all at once and then do something with all of the data.
(with-chart-of-accounts
(multiple-value-bind (query binds)
(sxql:yield
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5))))
(dbi:fetch (dbi:execute (dbi:prepare mito:*connection* query)
binds))))
; => (:|id| 5 :|name| "General Expenses"
; :|code| "GEN000" :|category| "expense"
; :|currency| "USD" :|created_at|
; "2025-11-30 03:59:02.662347Z"
; :|updated_at| "2025-11-30
; 03:59:02.662347Z")
(with-chart-of-accounts
(multiple-value-bind (query binds)
(sxql:yield
(sxql:select :*
(sxql:from :almighty_account)
(sxql:where (:= :almighty_account.id 5))))
(dbi:fetch-all (dbi:execute (dbi:prepare mito:*connection* query)
binds))))
; => ((:|id| 5 :|name| "General
; Expenses" :|code| "GEN000"
; :|category| "expense" :|currency|
; "USD" :|created_at| "2025-11-30
; 03:59:02.662347Z" :|updated_at|
; "2025-11-30 03:59:02.662347Z"))
; notice the extra parentheses
And that's the basics of using dbi. multiple-value-bind is the go-to macro
for working with forms that return multiple values like sxql:yield.
Rename to account-balance
Now that you know basically how we're going to use dbi to get our data, we can
get started on the task at hand.
First, rename the account-balance function to account-balance-slow. We'll
use it to test later.
+ (defun account-balance-slow (account &optional (as-of (universal-now))) +
....)
Don't forget to export it with sly-export-symbol-at-point (C-c x with the
cursor on function name).
Writing the query
Now we need to get to the SQL query. What we want to do is get all of the legs
for a certain account (as we do with account-legs) and then sum up the
entry_amount columns of all those legs. In the end, we need an amount and a
currency so we can properly make a money object later, so we're not going to
(select :*), we're going to return a sum.
(with-coa
(let ((account (ak:get-account "CSH000")))
(multiple-value-bind (query binds)
(s:yield
(s:select ((:as (:sum :leg.entry_amount) :amount))
(s:from (:as :almighty_account :account))
(s:inner-join (:as :almighty_leg :leg) :on (:= :leg.account_id
:account.id))
(s:where (:= :account.id (m:object-id account)))))
(d:fetch-all (d:execute (d:prepare m:*connection* query)
binds)))))
; => ((:|amount| 100000000))
That looks good, but let's just be sure with a smaller test.
(with-chart-of-accounts ; Using in-memory database
;; Use cash and revenue accounts.
(let ((cash (ak:get-account "CSH000"))
(revenue (ak:get-account "RMR000")))
;; Make transactions.
(ak:transact! (ak:debit cash 5000)
(ak:credit revenue 5000))
(ak:transact! (ak:debit cash 5000)
(ak:credit revenue 5000))
(ak:transact! (ak:debit cash 5000)
(ak:credit revenue 5000))
;; Reverse the transactions.
(ak:transact! (ak:credit cash 5000)
(ak:debit revenue 5000))
(ak:transact! (ak:credit cash 5000)
(ak:debit revenue 5000))
(ak:transact! (ak:credit cash 5000)
(ak:debit revenue 5000))
(multiple-value-bind (query binds)
(s:yield
(s:select ((:as (:sum :leg.entry_amount) :amount))
(s:from (:as :almighty_account :account))
(s:inner-join (:as :almighty_leg :leg) :on (:= :leg.account_id
:account.id))
;; Check cash balance.
(s:where (:= :account.id (m:object-id cash)))))
(d:fetch-all (d:execute (d:prepare m:*connection* query)
binds)))))
; => ((:|amount| 0))
At this point, we just need to add a way to filter for the as-of transaction
date, add the entry_currency to the return values, and turn this into a proper
function.
(defun sql-account-balance (account as-of)
(multiple-value-bind (query binds)
(s:yield (s:select ((:as (:coalesce (:sum :leg.entry_amount) 0) :amount)
(:as :leg.entry_currency :currency))
(s:from (:as :almighty_account :account))
(s:inner-join (:as :almighty_leg :leg) :on (:= :leg.account_id
:account.id))
(s:inner-join (:as :almighty_transaction :tran) :on (:=
:leg.transaction_id :tran.id))
(s:where (:and (:= :account.id (m:object-id account))
(:<= :tran.date as-of)))
(s:group-by :leg.entry_currency)))
(let ((result (d:fetch (d:execute (d:prepare m:*connection* query)
binds))))
(am:make-money (second result) (fourth result)))))
Then let's make a new account-balance function:
(defun account-balance (account &optional (as-of (universal-now)))
"A function for getting the balance of an account at a certain point in time,
defaulting to now.
Usage Example:
(account-balance \"CSH000\")
(account-balance sales-account-object)
"
(let ((account (if (stringp account)
(get-account account)
account))
(as-of (etypecase as-of
(lt:timestamp (lt:timestamp-to-universal as-of))
(transaction (lt:timestamp-to-universal (transaction-date as-of)))
(string (lt:timestamp-to-universal (lt:parse-timestring as-of)))
(integer as-of))))
(sql-account-balance account as-of)))
Now we can test it out against the slower version.
(time (with-coa (ak:account-balance-slow "CSH000")))
Results in:
Evaluation took:
0.675 seconds of real time
0.677828 seconds of total run time (0.638939 user, 0.038889 system)
[ Real times consist of 0.060 seconds GC time, and 0.615 seconds non-GC time. ]
[ Run times consist of 0.060 seconds GC time, and 0.618 seconds non-GC time. ]
100.44% CPU
7 forms interpreted
391,349,920 bytes consed
Now for the optimized version:
(time (with-coa (ak:account-balance "CSH000")))
Results in:
Evaluation took:
0.051 seconds of real time
0.044906 seconds of total run time (0.033658 user, 0.011248 system)
88.24% CPU
7 forms interpreted
262,080 bytes consed
Much better.
Example Usage
Now that we have the basic functionality of our library finished, it's time to
kick the tires a bit and see what it can do. First, we'll make it a REPL-based
application, but later we'll upgrade it into a CLI script in the chapter on
DEPLOYING.
Setup
In the project root directory, make a new directory named example.
We need a system for this example, so let's set that up.
;; almighty-kaikei/almighty-kaikei-examples.asd
(defsystem "almighty-kaikei-examples"
:author "Micah Killian"
:version "0.0.1"
:description "Examples of how to use the Almighty-Kaikei library."
:depends-on (#:almighty-money #:almighty-kaikei #:local-time #:mito #:sxql
#:dbi)
:components ((:module "examples"
:serial t
:components ((:file "db")
(:file "views")))))
Before we work on our queries, it'll help to know the motivation for getting the data by looking at our views.
Views
First, let's get our package set up in examples/views.lisp.
(defpackage #:examples/views
(:use #:cl)
(:local-nicknames (#:db #:examples/db)
(#:lt #:local-time)
(#:ak #:almighty-kaikei)
(#:am #:almighty-money)))
(in-package #:examples/views)
In here, we're going to create some simple reports. The first one is a general ledger. The most basic kind of accounting report is the general ledger. It simply contains all of the transactions made, detailing the two sides and accounts affected.
;; * ░ VIEWS ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
(defun format-general-ledger (from to)
(let ((transaction-count (db:transaction-count-for-period from to))
(legs (db:legs-for-period from to)))
(format t "~&Showing ~a transactions.~%" transaction-count)
(format t "~a~30t~a~60t~a~%" "Transaction #" "Debits" "Credits")
(loop :for leg :in legs
:for i = 0
:if (and (ak:debit-p leg) (= 0 i))
:do (format t "~a~30t~30a~%" (ak:leg-transaction-id leg) (list (am:format-money
nil (ak:leg-entry leg)) (ak:account-name (ak:leg-account leg))))
:else
:do (format t "~a~60t~30a~%" (ak:leg-transaction-id leg) (list (am:format-money
nil (ak:leg-entry leg)) (ak:account-name (ak:leg-account leg))))
:finally (format t "~%"))))
This function will
Queries
Let's make examples/db.lisp and define the package:
(defpackage #:examples/db
(:use #:cl)
(:local-nicknames (#:lt #:local-time)
(#:m #:mito)
(#:s #:sxql)
(#:ak #:almighty-kaikei)
(#:am #:almighty-money)))
(in-package #:examples/db)
This package will be in charge of helping us work with the database: establishing a connection, select queries, and other "business logic" queries.
(defmacro with-universals (bindings &body body)
`(let ,(loop :for (var val) :in bindings
:collect (list var
`(lt:timestamp-to-universal
(if (stringp ,val)
(lt:parse-timestring ,val)
,val))))
,@body))
(defmacro with-db-connection (&optional (db-name "sqlite.db") &body body)
`(let ((mito:*connection* (dbi:connect-cached :sqlite3 :database-name ,db-name)))
(unwind-protect (progn ,@body)
(dbi:disconnect mito:*connection*))))
(defun db-path ()
(let ((db-path (asdf:system-relative-pathname :almighty-kaikei-examples #p"
data/sqlite.db")))
(ensure-directories-exist db-path)
db-path))
(defun initialize-application-database ()
(with-db-connection (db-path)
(let (
;; Asset Accounts
(cash-account (ak:make-account "Cash" "
CSH000" "asset"))
(inventory-account (ak:make-account "Inventory" "
INV000" "asset"))
(accounts-receivable-account (ak:make-account "Accounts Receivable" "
REC000" "asset"))
;; Expense Accounts
(cost-of-goods-sold-account (ak:make-account "Cost of Goods Sold" "
CGS000" "expense"))
(general-expenses-account (ak:make-account "General Expenses" "
GEN000" "expense"))
;; Income Account
(sales-revenue-account (ak:make-account "Sales Revenue" "
SLS000" "income"))
;; Liability Account
(tax-account (ak:make-account "Tax Payable" "
TAX000" "liability"))
;; Equity Accounts
(room-revenue-account (ak:make-account "Room Revenue" "
RMR000" "equity")))
(m:ensure-table-exists 'ak:account)
(m:ensure-table-exists 'ak:transaction)
(m:ensure-table-exists 'ak:leg)
;; Asset
(ak:get-or-save-account cash-account)
(ak:get-or-save-account inventory-account)
(ak:get-or-save-account accounts-receivable-account)
;; Expense
(ak:get-or-save-account cost-of-goods-sold-account)
(ak:get-or-save-account general-expenses-account)
;; Income
(ak:get-or-save-account sales-revenue-account)
;; Liablity
(ak:get-or-save-account tax-account)
;; Equity
(ak:get-or-save-account room-revenue-account))))
To begin with, we have with-universals. This converts local-time:timestamp
objects into Common Lisp universal times. According to the HyperSpec
(https://cl-community-spec.github.io/pages/Universal-Time.html),
Universal time is an absolute time represented as a single non-negative integer—the number of seconds since midnight, January 1, 1900 GMT (ignoring leap seconds).
Earlier, we wrote the transaction deftable in order to automatically
serialize timestamps to universal times as well as the reverse. For our queries,
if we ever want to filter rows by timestamps, we might be tempted to use
local-time:timestamp objects directly, but they don't work. We could try
converting timestamps into a format that SQLite understands that still looks
like a timestamp, but for our purposes, a simple integer will do.
with-db-connection is a simple macro for running some code with a database
connection. mito:*connection* needs to be set to a dbi connection, which we
set to sqlite3. unwind-protect is a macro that will always run the form of its
second argument even if the first argument form has some error. It's useful for
any necessary cleanup after a form does some work, perhaps saving some data to a
file, but then fails in the middle. In our case, we use it to close the
connection.
db-path ensures that we have a place to put our database file (if the data
folder isn't already created, for example).
initialize-application-database is for ensuring the almighty-kaikei tables
are available and our Chart of Accounts is created. When we first start our
application, we need to run this function.
General ledger
The most basic kind of accounting report is the General Ledger. It simply contains all of the transactions made, detailing the two sides and accounts affected.
(defun sales (from to)
(with-universals ((from from)
(to to))
(let* ((transaction-count (second (first (m:retrieve-by-sql
(s:select ((:count :*))
(s:from :almighty_transaction)
(s:inner-join :almighty_leg :on (:=
:almighty_leg.transaction_id :almighty_transaction.id))
(s:where (:and
(:=
:almighty_leg.account_id (m:object-id (ak:get-account "RMR000")))
(:>=
:almighty_transaction.date from)
(:<=
:almighty_transaction.date to))))))))
(sales-account (ak:get-account "RMR000"))
(legs (m:select-dao 'ak:leg
(m:joins 'ak:transaction)
(s:where (:and
(:= :almighty_leg.account-id (m:object-id
sales-account))
(:>= :almighty_transaction.date from)
(:<= :almighty_transaction.date to)))
(s:order-by :almighty_transaction.id))))
(format t "~&Showing ~a transactions.~%" transaction-count)
(format t "~a~15t~a~45t~a~%" "Transaction #" "Debits" "Credits")
(loop :for leg :in legs
:for i = 0
:if (and (ak:debit-p leg) (= 0 i))
:do (format t "~a~15t~a~%" (ak:leg-transaction-id leg) (list (am:format-money
nil (ak:leg-entry leg)) (ak:account-name (ak:leg-account leg))))
:else
:do (format t "~a~45t~a~%" (ak:leg-transaction-id leg) (list (am:format-money
nil (ak:leg-entry leg)) (ak:account-name (ak:leg-account leg))))
:finally (format t "~%")))))
general-ledger uses a with-universals macro for taking two local-time
"timestrings" and converting them to universal times. When making queries like
this, universal times are easier to use than trying to work with local-time
timestamps–that's why we set up transaction dates to inflate/deflate
(serialize/deserialize) between timestamps and universal times earlier.
Here's with-universals:
(defmacro with-universals (bindings &body body)
`(let ,(loop :for (var val) :in bindings
:collect (list var
`(lt:timestamp-to-universal
(if (stringp ,val)
(lt:parse-timestring ,val)
,val))))
,@body))
It takes bindings, a list of lists (as in a let form) with a variable and
the value to bind it to. It takes a string and converts it to a universal time.
A simple but useful for reports like general-ledger that want to show data for
a certain time range.
The transaction-count variable is bound to the result of a simple SQL query
that gets the number of transactions for a time range.
legs is similar, with one small difference: it uses mito:joins–the
equivalent of typing in inner-join :table_name :on .... Since we're using
select-dao, we can use the joins macro to save a little bit of typing.
After that, we just go through the list of leg objects and format them. The
TILDE t format directive here is useful for making tabular output. ~nt is
padded to the nth space. ~15t means "If you aren't at column 15 yet, move
there before continuing to format."
Output of general-ledger should look like this:
Showing 300 transactions.
Transaction # Debits Credits
1 ($50.00 Cash)
1 ($50.00 Room Revenue)
2 ($50.00 Cash)
2 ($50.00 Room Revenue)
3 ($50.00 Cash)
3 ($50.00 Room Revenue)
4 ($50.00 Cash)
4 ($50.00 Room Revenue)
We're implementing these reports using format, but we could just as easily use
HTML to format them for the web. For simplicity we're keeping the query
definition and the "front end" together in one function.
Trial balance
A trial balance shows the net total balance of all accounts.
Tests
At this point, we need to start thinking about testing a little more seriously. We want to be able to test not only our ability to get account balances, but to do so at specific times. We need to be able to set up an environment for our tests that won't affect the other tests. What we want is essentially a lexical database environment.
So now we need to get systematic with testing, and for that we'll use
lisp-unit2
Getting Started with lisp-unit2
To get started, open a buffer in t/main.lisp and add this:
(defpackage #:almighty-kaikei/test/main
(:use #:cl)
(:local-nicknames (#:lu #:lisp-unit2)
(#:lt #:local-time)
(#:ak #:almighty-kaikei)
(#:am #:almighty-money)))
(in-package #:almighty-kaikei/test/main)
Making a test
Now let's make a simple test to learn how to use lisp-unit2.
(lu:define-test testing-testing-123 ()
(lu:assert-eql 123 123))
This test will pass if (eql 123 123) or fail otherwise. To run the test, we can
use lu:run-tests.
(lu:run-tests)
It should return something like this:
#<LU:TEST-RESULTS-DB Tests:(1) Passed:(1) Failed:(0) Errors:(0) Warnings:(0) {700C0D01D3}>
Contexts
In addition to tags, you can specify contexts. Contexts are lexical environments
for tests. You can specify them on a per-test basis or for all tests in the call
to lu:run-tests.
(lu:define-test test-my-context-text (:contexts (list #'with-my-context
#'with-another-context)))
(lu:run-tests :run-contexts (list #'lu:with-failure-debugging-context))
lu:with-failure-debugging-context is a built-in context that will open the
debugger if an assertion fails, giving you an opportunity to look at the data.
Contexts are functions that return a funcall to a body function.
(defun with-my-context (body-fn)
(let ((...))
(do-thing)
(do-another-thing)
(funcall body-fn)))
Every test that uses with-my-custom-context will include this lexical
environment.
Making testing contexts
In order to run our tests, we need to be able to do several things for every test:
- Connect and disconnect to a database.
- Create and destroy our database tables.
- Create a standard set of accounts (called a Chart of Accounts).
First, let's create the function and macro for connecting to the database:
(defmacro with-db-connection (&optional (db-name "sqlite.db") &body body)
`(let ((mito:*connection* (dbi:connect-cached :sqlite3 :database-name ,db-name)))
(unwind-protect (progn ,@body)
(dbi:disconnect mito:*connection*))))
(defun test-db-path ()
(let ((db-path (asdf:system-relative-pathname :almighty-kaikei #p"t/test.db")))
(ensure-directories-exist db-path)
db-path))
Next, we need to make a "chart of accounts" lexical database environment–"fixtures". To reiterate, there are five different categories of accounts, and a chart of accounts may have several of each category. In our context, we will make the SQL tables and then add the accounts to the database.
(defun with-chart-of-accounts-context (body-fn)
(with-db-connection #p":memory:"
(m:ensure-table-exists 'ak:account)
(m:ensure-table-exists 'ak:transaction)
(m:ensure-table-exists 'ak:leg)
(let (
;; Asset Accounts
(cash-account (ak:make-account "Cash" "
CSH000" "asset"))
(inventory-account (ak:make-account "Inventory" "
INV000" "asset"))
(accounts-receivable-account (ak:make-account "Accounts Receivable" "
REC000" "asset"))
;; Expense Accounts
(cost-of-goods-sold-account (ak:make-account "Cost of Goods Sold" "
CGS000" "expense"))
(general-expenses-account (ak:make-account "General Expenses" "
GEN000" "expense"))
;; Income Account
(sales-revenue-account (ak:make-account "Sales Revenue" "
SLS000" "equity"))
;; Liability Account
(liability-account (ak:make-account "Liabilities" "
LIA000" "liability"))
;; Equity Accounts
(room-revenue-account (ak:make-account "Room Revenue" "
RMR000" "equity")))
;; Asset
(ak:get-or-save-account cash-account)
(ak:get-or-save-account inventory-account)
(ak:get-or-save-account accounts-receivable-account)
;; Expense
(ak:get-or-save-account cost-of-goods-sold-account)
(ak:get-or-save-account general-expenses-account)
;; Income
(ak:get-or-save-account sales-revenue-account)
;; Liablity
(ak:get-or-save-account liability-account)
;; Equity
(ak:get-or-save-account room-revenue-account)
(funcall body-fn))))
This is the context that we'll use for our lisp-unit2 tests. It uses SQLite's
in-memory database, making it temporary for the duration of each connection.
We also want a way to mess around outside of the tests, so let's make a macro that lets us do that:
(defmacro with-coa (&body body)
`(with-db-connection (test-db-path)
(let (
;; Asset Accounts
(cash-account (ak:make-account "Cash" "
CSH000" "asset"))
(inventory-account (ak:make-account "Inventory" "
INV000" "asset"))
(accounts-receivable-account (ak:make-account "Accounts Receivable" "
REC000" "asset"))
;; Expense Accounts
(cost-of-goods-sold-account (ak:make-account "Cost of Goods Sold" "
CGS000" "expense"))
(general-expenses-account (ak:make-account "General Expenses" "
GEN000" "expense"))
;; Income Account
(sales-revenue-account (ak:make-account "Sales Revenue" "
SLS000" "income"))
;; Liability Account
(liability-account (ak:make-account "Liabilities" "
LIA000" "liability"))
;; Equity Accounts
(room-revenue-account (ak:make-account "Room Revenue" "
RMR000" "equity")))
(m:ensure-table-exists 'ak:account)
(m:ensure-table-exists 'ak:transaction)
(m:ensure-table-exists 'ak:leg)
;; Asset
(ak:get-or-save-account cash-account)
(ak:get-or-save-account inventory-account)
(ak:get-or-save-account accounts-receivable-account)
;; Expense
(ak:get-or-save-account cost-of-goods-sold-account)
(ak:get-or-save-account general-expenses-account)
;; Income
(ak:get-or-save-account sales-revenue-account)
;; Liablity
(ak:get-or-save-account liability-account)
;; Equity
(ak:get-or-save-account room-revenue-account)
,@body)))
Using with-coa will cause a .db file to be created that we can inspect and run
queries on.
Making tests
So now it's time to make some tests. We'll start with some warm-up tests just to see if we can do basic stuff.
;; * ░ Accounting ░░░░░░░░░░░░░░░░░░░░ For our accounting scenarios, we will
;; imagine the accounting for a hotel. The hotel sells stays, has a restaurant,
;; and rooms have snack fridges. They take cash and credit.
(lu:define-test tests-get-account (:tags '(accounting) :contexts (list
#'with-chart-of-accounts-context))
(lu:assert-true (ak:get-account "CSH000")))
(lu:define-test test-account-code (:tags '(accounting) :contexts (list
#'with-chart-of-accounts-context))
(lu:assert-equal "CSH000" (ak:account-code (ak:get-account "CSH000"))))
(lu:define-test test-make-account (:tags '(accounting) :contexts (list
#'with-chart-of-accounts-context))
(lu:assert-true (typep (ak:make-account "Expenses" "EXP" "expense")
'ak:account)))
(lu:define-test test-get-or-create-account (:tags '(accounting) :contexts (list
#'with-chart-of-accounts-context))
(let ((new-account (ak:get-or-save-account (ak:make-account "More Assets" "
ASS001" "asset"))))
(lu:assert-no-error 'error (ak:account-code (ak:get-account "ASS001")))))
These tests check to see if everything related to getting or making accounts is
working. We can run these with (lu:run-tests). If everything passes, great. If
you have a failing test, run (lu:run-tests :run-contexts (list
#'lu:with-failure-debugging-context)).
Assuming you have everything settled with the above tests, let's start getting into the meat of things. Remember that the purpose of these tests is to confirm both that the system is working and that we understand how the accounting works.
;; * ░ Transactions ░░░░░░░░░░░░░░░░░░░░ * ░ Card Sale of Service
;; *░░░░░░░░░░░░░░░░░░░░ ;; This scenario involves tracking: ;; 1. A card
;; *payment going to our Receivable account. ;; 2. Card payment provider
;; *expense. ;; 3. The revenue after paying that expense.
(lu:define-test test-transact!-card-sale-of-service (:tags '(accounting
transactions)
:contexts (list
#'with-chart-of-accounts-context))
(ak:transact! (ak:debit "REC000" 4500)
(ak:debit "GEN000" 500)
(ak:credit "RMR000" 5000))
(lu:assert-equality #'am:money= (ak:account-balance (ak:get-account "REC000")) (am:usd
4500))
(lu:assert-equality #'am:money= (ak:account-balance (ak:get-account "GEN000")) (am:usd
500))
(lu:assert-equality #'am:money= (ak:account-balance (ak:get-account "RMR000")) (am:usd
5000)))
(lu:define-test test-transact!-unbalanced-sides (:tags '(accounting transactions)
:contexts (list
#'with-chart-of-accounts-context))
(lu:assert-error 'ak:unbalanced-transaction (ak:transact! (ak:debit "CSH000"
5000)
(ak:credit "RMR000"
4000))))
(lu:define-test test-transact!-not-enough-legs (:tags '(accounting transactions)
:contexts (list
#'with-chart-of-accounts-context))
(lu:assert-error 'simple-error (ak:transact! (ak:debit "CSH000" 5000))))
Recall that we're considering the example of a hotel's finances. In the
card-cale-of-service scenario, we receive a credit card payment for a room. We
credit our Room Revenue income account 5000 cents ($50)–increasing its value.
The card payment service charges us 500 cents ($5) that is debited to our
general expenses account–adding to its value. Finally we credit our Receivable
account the remainder of the money, 4500 cents ($45), which is what we expect to
actually have after paying our expenses.
Since the test begins with no transactions in the database, all three accounts should have account balances equal the amounts they've been increased by.
The second test just checks that our unbalanced-transaction condition works
properly. The third checks to make sure we receive an error if there aren't at
least two legs in the transaction.
We'll make a couple more tests just to confirm that our understanding of the principles of double-entry accounting are solid.
;; ** ░ Cash Sale of Goods ░░░░░░░░░░░░░░░░░░░░ This scenario involves tracking:
;; 1. The cash paid. 2. The revenue earned. 3. The cost of the good sold. 4. The
;; reduction in inventory of the good. 5. The total profit (revenue - cogs)
(lu:define-test test-cash-sale-of-goods (:tags '(accounting transactions)
:contexts (list
#'with-chart-of-accounts-context))
(let ((cash-account "CSH000")
(sales-account "SLS000")
(cogs-account "CGS000")
(inventory-account "INV000"))
(ak:transact! (ak:debit cash-account 5000) (ak:credit sales-account 5000)
(ak:debit inventory-account 3000) (ak:credit cogs-account 3000))
(lu:assert-equality #'am:money= (ak:account-balance cash-account) (am:usd
5000))
(lu:assert-equality #'am:money= (ak:account-balance sales-account) (am:usd
5000))
(lu:assert-equality #'am:money= (ak:account-balance cogs-account) (am:usd -3000)) ; Crediting
; expense
; account
; decreases
; its
; value.
(lu:assert-equality #'am:money= (ak:account-balance inventory-account) (am:usd
3000))
(lu:assert-equality #'am:money=
(am:money+ (ak:account-balance sales-account) (ak:account-balance
cogs-account))
(am:usd 2000))))
;; ** ░ Credit Card Sale of Hotel Stay ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
;; This scenario involves tracking: 1. Accounts Receivable (Asset) for credit
;; card payment. 2. The room revenue (Equity) earned. 3. Credit card processing
;; fees (Expense)
(lu:define-test test-debit-card-sale-of-hotel-stay (:tags '(accounting
transactions)
:contexts (list
#'with-chart-of-accounts-context))
(let ((receivable-asset-account "REC000")
(general-expense-account "GEN000")
(room-revenue-account "RMR000")
(cash-asset-account "CSH000"))
;; Sale is made--debit Receivable Account for card payment, debit general
;; expenses for card fees. Credit Room Revenue for sale.
(ak:transact! (ak:credit room-revenue-account (am:usd 5000))
(ak:debit general-expense-account (am:usd 500))
(ak:debit receivable-asset-account (am:usd 4500)))
(lu:assert-equality #'am:money=
(ak:account-balance receivable-asset-account)
(am:usd 4500))
(lu:assert-equality #'am:money=
(ak:account-balance general-expense-account)
(am:usd 500))
(lu:assert-equality #'am:money=
(ak:account-balance room-revenue-account)
(am:usd 5000))
;; Bank fulfills payment; clear receivable and add to cash
(ak:transact! (ak:debit cash-asset-account (am:usd 4500))
(ak:credit receivable-asset-account (am:usd 4500)))
(lu:assert-equality #'am:money=
(ak:account-balance receivable-asset-account)
(am:usd 0))
(lu:assert-equality #'am:money=
(ak:account-balance cash-asset-account)
(am:usd 4500))))
Call (lu:run-tests) again. Everything pass? Good. Let's test one more
important feature: the as-of parameter of account-balance.
(lu:define-test test-account-legs-as-of
(:tags '(query) :contexts (list #'with-chart-of-accounts-context))
(let ((cash (ak:get-account "CSH000"))
(sale (ak:get-account "SLS000"))
(date (lt:now)))
(let ((1st (ak:transact! (ak:debit cash (am:usd 500))
(ak:credit sale (am:usd 500))
date))
(2nd (ak:transact! (ak:debit cash (am:usd 500))
(ak:credit sale (am:usd 500))
(lt:timestamp+ date 10 :sec))))
(lu:assert-number-equal 1 (length (ak:account-legs cash (lt:timestamp+ (ak:transaction-date
2nd) -1 :sec)))))))
Here we make two transactions, one ten seconds after the other. We set the
as-of parameter to one second before the second transaction. We expect for
the cash account that we should only have one leg–the one for the first
transaction. Call (lu:run-tests) and check if this passes. It does? Good.

