Morty is an accountant.
Depends on:
- Rails 7.0+ (tested on 7.0, 7.1, 7.2, 8.0, 8.1)
- Ruby 3.2+ (tested on 3.2, 3.3, 3.4, 4.0)
- LookupBy (edge, for now)
- PostgreSQL 9.5+ (tested on 16)
Source code
Add this line to your application's Gemfile:
gem 'morty'
And then execute:
$ bundle install
Run migrations (Morty is an engine and includes its own migrations):
$ rake db:migrate
The migration creates two PostgreSQL schemas:
morty... ledgers, account_types, accounts, activity_types, activities, entries, and a set of accounting views (balances, details, drs/crs)morty_archive... mirrors the activities and entries tables for closed-period data, with foreign key constraints removed for fast bulk inserts
Both schemas enforce write-only access at the database level. Triggers prevent updates and deletes on activities, entries, and entry_types unless explicitly overridden.
- Double-entry bookkeeping ... every activity creates balanced debit/credit entries
- Write-only ledger ... activities and entries cannot be updated or deleted at the database level; corrections are made through cancellations and reversals
- Multiple ledgers ... run different accounting strategies side by side (e.g. conservative vs aggressive interest accrual) against the same source
- Retroactive activities ... backdate an activity with
effective_date:and Morty automatically recalculates all affected entries from that date forward - In-memory simulation ... simulate days forward, running daily logic and scheduled activities, before deciding whether to persist
- Effective rates ... attach rate schedules that change over time;
rate_for(date)resolves the correct rate for any point in history - Period archival ... move closed-period data to
morty_archiveto keep the active schema lean
Morty uses double-entry bookkeeping. You define an Accountant subclass that
declares activities (transactions) and how they move money between accounts.
class LoanAccountant < Morty::Accountant
# The source ties all activities to a domain object (e.g. a Loan).
source :loan
# Named balances aggregate one or more accounts.
balance :accruing, %w[principal]
# Activities describe double-entry transactions.
# Each entry debits one account and credits another.
activity :issue do
entry :principal, :cash, amount
end
activity :interest do
entry :interest, :revenue, amount
end
# Waterfalls apply a payment across accounts in priority order.
# `limit: :cr` caps each entry at the credit account's balance.
# `complete: true` sends any remainder to the last account.
waterfall :payment, limit: :cr, complete: true, entries: <<~END
cash interest
cash principal
cash payable
END
# Daily logic runs once per simulated day.
daily do
activity :interest, today, rate.daily * balances[:accruing]
end
# Guard prevents duplicate daily entries.
daily_guard do
accountant.activities.with_type(:interest).none? { |a| a.effective_date == accountant.date }
end
endMorty needs account types, accounts, entry types, and activity types seeded before use. The engine includes a base seed file; add your domain-specific seeds on top:
# Load Morty's base seeds (account types, built-in activity types)
Morty::Engine.load_seed
# Your ledger
Morty::Ledger.seed 'default'
# Accounts: pairs of [account_type_abbreviation, account_name]
Morty::Seed.accounts %w[
A cash
A principal
A interest
L payable
R revenue
]
# Entry types: pairs of [debit_account, credit_account]
# Morty creates the reverse entry type automatically.
Morty::Seed.entry_types :default, %w[
cash principal
cash interest
cash payable
interest revenue
]
# Activity types your accountant uses
Morty::ActivityType.seed *%w[issue interest payment]accountant = LoanAccountant.new
accountant.source = loan # any object with an #id method
accountant.start_date = loan.funded_on
# Set interest rates (annual, keyed by effective date)
accountant.rates = {
"2026-01-01" => 0.365, # 36.5% APR
"2026-07-01" => 0.24, # drops to 24% APR on July 1
}
# Record a single activity
accountant.activity :issue, "2026-01-01", 5000
# Pass an idempotent_uuid to safely retry from webhooks or queues
accountant.activity :payment, "2026-02-01", 500, idempotent_uuid: "550e8400-..."
# Set a schedule of future activities
accountant.schedule = [
{ type: :payment, date: "2026-02-01", amount: 500 },
{ type: :payment, date: "2026-03-01", amount: 500 },
]
# Simulate forward in time (runs daily logic + scheduled activities for each day)
accountant.simulate_to("2026-03-01")
# Check balances
accountant.balances # => { accruing: 4123.45 }
accountant.accounts # => { cash: -4000, principal: 4123.45, interest: 0, ... }
# Save all activities and entries to the database
accountant.savesimulate_to is a shortcut for the more flexible simulate block. Inside
a simulation block, you can interleave activities with time advancement
using finish:
accountant.simulate do
issue "2026-01-01", 1000
payment "2026-01-02", 1000
end
# finish advances the simulation day-by-day (running daily logic each day)
# without recording an activity:
accountant.simulate do
issue "2026-01-01", 1000
finish "2026-01-11" # run daily interest through Jan 11
endThe simulation runs entirely in memory. Nothing is persisted until you
call accountant.save. This makes it cheap to explore scenarios:
# What would balances look like if we waited until March?
trial = accountant.adjusting_accountant
trial.simulate_to("2026-03-01")
trial.balances # => { accruing: 4500.00 }
# nothing saved, original accountant unchanged# Cancel an activity (creates a cancelling counter-entry)
incorrect = accountant.activities.last
accountant.cancel(incorrect)
# Reverse an activity (creates reversal entries)
accountant.reverse(prior_activity)Record an activity today that takes effect in the past. Morty replays history from the effective date forward and generates an adjustment entry to correct all downstream balances (e.g. accrued interest).
# Issue was funded on Jan 1, but we're recording it on Jan 3
accountant.simulate_to("2026-01-03")
accountant.activity :issue, "2026-01-03", 1000, effective_date: "2026-01-01"
# Morty automatically:
# 1. Replays simulation from the effective date
# 2. Recalculates daily interest from Jan 1 through Jan 3
# 3. Creates an :adjustment activity with correcting entries
accountant.activities.count_by_type
# => { issue: 1, interest: 2, adjustment: 1 }Cancelling a retroactive activity also triggers readjustment:
# Cancel the payment that was effective Jan 1
accountant.cancel(payment_activity)
# Interest is recalculated as if the payment never happenedEvery activity has a debug method that prints a colorized ledger view:
activity = accountant.activities.last
activity.debug
# Issue $5000.00 2026-01-01
#
# Default ledger entries
#
# | DR | CR
# ------------|-----------|----------
# principal | 5000.00 |
# cash | | 5000.00
You can also inspect the activity list:
accountant.activities.each { |a| puts a.inspect }
# <Activity[new] $ 5000.00 2026-01-01 issue>
# <Activity[new] $ 1.00 2026-01-02 interest>
# Filter by type
accountant.activities.with_type(:interest).count
accountant.activities.count_by_type # => { issue: 1, interest: 31, payment: 2 }
# Inspect an activity's entries
activity.entries.each { |e| puts e.inspect }
# <Entry[new] $5000.00 default DR[principal] CR[cash]>
# Each entry exposes its debit/credit accounts and ledger
entry = activity.entries.first
entry.dr # => :principal
entry.cr # => :cash
entry.amount # => 5000.0
entry.ledger # => :defaultMorty provides three PostgreSQL functions for managing historical data. Call them directly via SQL.
Close a period ... marks a ledger as closed through a given date. The ledger must balance (debits equal credits) through that date or the call raises an exception.
SELECT morty.close_period(1, '2025-12-31');Archive by source ... moves all activities and entries for a source
into morty_archive. The source must have no entries in any open period.
SELECT * FROM morty.archive_source(1234);
-- => archived_activities | archived_entries
-- 47 | 312Archive by date ... moves all activities and entries on or before a
date into morty_archive. All ledgers must be closed through that date
first.
SELECT * FROM morty.archive_through('2025-12-31');
-- => archived_activities | archived_entries
-- 10842 | 89431Archived data is queryable through the same view structure in the
morty_archive schema. The archive tables mirror the active schema but
without foreign key constraints, so bulk inserts are fast.
Morty uses rspec and cucumber. Install them:
$ bundle install
Run the test suite:
$ rake db:reset
$ rake
Morty ships reusable Cucumber steps. Require them in your features/support/env.rb:
require "morty/cucumber/steps"Then write features using the built-in step vocabulary:
Feature: Loan lifecycle
Scenario: Issue and pay down a loan
Given a loan accountant
And a start date of 2026-01-01
And an interest rate of 36.5%
And the schedule:
| payment | 2026-02-01 | 500.00 |
| payment | 2026-03-01 | 500.00 |
When I simulate until 2026-03-01
Then I have these balances:
| cash | -4000.00 |
| principal | 4123.45 |
| interest | 0.00 |
| revenue | -123.45 |Setup:
| Step | Example |
|---|---|
Given a <type> accountant |
Given a loan accountant |
Given a start date of <date> |
Given a start date of 2026-01-01 |
Given an interest rate of <rate>% |
Given an interest rate of 36.5% |
Given the schedule: |
Table of | type | date | amount | rows |
Given the interest rates: |
Table of | date | rate | rows |
Actions:
| Step | Example |
|---|---|
When I simulate to/until <date> |
When I simulate until 2026-03-01 |
When I simulate these activities: |
Table of | type | date | amount | rows |
When I apply a <type> for $<amount> |
When I apply a payment for $500.00 |
When I apply a <type> effective <date> for $<amount> |
Retroactive activity |
When I cancel the <date> <type> |
When I cancel the 2026-01-15 payment |
When I return the <date> <type> |
When I return the 2026-01-15 payment |
When I reverse the <date> <type> |
When I reverse the 2026-01-15 interest |
When I save and reload the accountant |
Persists and reloads from the database |
Assertions:
| Step | Example |
|---|---|
Then I have these balances: |
Table of | account | amount | rows |
Then all zero balances |
All accounts are zero |
Then I have <n> activities |
Then I have 5 activities |
Then I have these activity counts: |
Table of | type | count | rows |
Then the <name> ledger has these balances: |
For multi-ledger accountants |
The accountant step (Given a loan accountant) expects a class named
LoanAccountant to exist. Define your accountant classes in
features/support/ so they're loaded before your features run.
- Fork it ( https://github.com/[my-github-username]/morty/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
The gem is available as open source under the terms of the MIT License.
Copyright © 2025-2026 Erik Peterson. Licensed under the MIT License.