Project

generalis

0.0
Low commit activity in last 3 years
No release in over a year
General Ledger for Ruby of Rails
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Runtime

>= 5, < 7
 Project Readme

Generalis

Generalis is a financial general ledger for ActiveRecord. It incorporates a light DSL for defining ledger transactions and connecting financial records to your existing models, built-in currency support, and RSpec integrations.

If DSLs are not to your liking, Generalis also provides support for ad-hoc transactions that behave more like plain-old ActiveRecord models.

Generalis currently only supports and is tested against PostgreSQL, but support for other database systems is planned.

Installation

Add this line to your application's Gemfile:

gem 'generalis'

And then execute:

$ bundle install

To generate the classes and configuration used by Generalis, run:

$ bin/rails generate generalis:install

To generate the migration files for the supporting database tables, run:

$ bin/rails generate generalis:migrations

And then run:

$ bin/rails db:migrate

MoneyRails Integration

Generalis relies on MoneyRails to operate and handle currencies correctly. It's not necessary to add it to your Gemfile directly, however, it is necessary to generate the configuration initializer:

https://github.com/RubyMoney/money-rails#installation

Ledger Accounts

Generalis includes 4 of the most common major account types:

Account Type Balance Behaviour
Asset Debit Normal
Expense Debit Normal
Liability Credit Normal
Revenue Credit Normal

These account types can be accessed as follows:

  • Generalis::Asset
  • Generalis::Expense
  • Generalis::Liability
  • Generalis::Revenue

Additional account types can be defined if necessary. (See the section on custom account types.)

Ledger accounts can be either global or associated to a particular record. The differences between the to mechanisms is explained in detail below.

Global Accounts

Global ledger accounts are typically those that are associated with your own business. For example, your company's own cash, revenue, and expenses would typically be global accounts in Generalis.

For accounts which pertain to a particular client or customer (like owed balance or store credits), see the section on Associated Accounts.

Defining Global Accounts

Global accounts can be created with the define(...) helper, which will automatically create the account if it doesn't already exist:

Generalis::Asset.define(:cash)

Global accounts are unique based on their name, so only one global account (of any type) can exist with a given name. It's typical practice to define your global accounts ahead of time, as a seed.

Retrieving Global Accounts

Global accounts can be retrieved by their name using either [] index notation or by using the .lookup() helper method:

cash = Generalis::Asset[:cash]

# OR

cash = Generalis::Asset.lookup(:cash)

Both methods above will raise an ActiveRecord::RecordNotFound error if the requested account does not exist.

Generalis accounts are just plain-old ActiveRecord objects, so it's also possible to use all the normal query methods like .where(...) and .find_by(...).

Associated Accounts

Associated ledger accounts are used to represent balances that belong to a particular client, customer, or some other record in your system. For example, a balance owed by a particular customer or a store credit issued to a customer would be modeled by an associated account.

When using associated accounts, multiple ledger account records may share the same name, but will be uniquely distinguished by their owner association. The balances each of these owner records is therefore tracked separately.

Defining Associated Accounts

Generalis provides an Accountable concern that should be included into your application's ActiveRecord models to automatically associate ledger accounts.

For example, to create an Asset account called "accounts_receivable" for a customer model, use:

class Customer < ApplicationRecord
  include Generalis::Accountable

  has_asset_account :accounts_receivable
end

DSL macros are available for all four types of supported accounts:

  has_asset_account :name
  has_expense_account :name
  has_liability_account :name
  has_revenue_account :name

The associated account can then be accessed just like any standard association:

customer = Customer.create!(...)

customer.accounts_receivable # => #<Generalis::Asset:0x0000... >

It's also possible to access the account using the same helpers methods as global accounts, by specifying the owner record:

customer = Customer.create!(...)

Generalis::Asset[:accounts_receivable, owner: customer] # => #<Generalis::Asset:0x0000... >

Manual Account Creation

By default, associated accounts are created automatically together with the accountable record. It's possible to disable this behaviour using:

  has_asset_account :accounts_receivable, auto_create: false

The associated account can be created later using a built-in helper:

customer = Customer.create!(...)

customer.create_accounts_receivable # => #<Generalis::Asset:0x00000... >

To locate records that are missing an associated account, a scope is automatically provided:

customers_missing_accounts = Customer.without_accounts_receivable

Dependent Account Behaviour

By default, associated accounts are treated as dependent: :restrict_with_error. This means that trying to delete a record with associated accounts will be prevented and ActiveModel::Errors will be set.

This behaviour can be changed using:

  has_asset_account :accounts_receivable, dependent: :destroy

NOTE: Accounts with associated ledger entries cannot be deleted as doing so would interfere with the state of the ledger. One possible option to circumvent this limitation would be to leave an orphaned record for the account.

This can be done with:

  has_asset_account :accounts_receivable, dependent: false

Balances and Currency Support

Generalis has first-class support for currency built-in, however, it doesn't perform any automatic exchange or normalization.

Instead, each currency is stored as a separate balance on the account, for example:

cash = Generalis::Asset[:cash]

cash.balance('CAD') # => #<Money $100.00>
cash.balance('USD') # => #<Money $0.00>
cash.balance('EUR') # => #<Money €25.00>

Requesting the balance of a currency that does not appear on an account will return 0 (as a Money object).

It's also possible to request a summary of all balances on an account:

cash.balances # => {"CAD"=>#<Money $100.00>,"EUR"=>#<Money €25.00>}

Custom Account Types

Generalis allows additional account types to be defined if necessary. For example, if you wished to define an equity account type, you would add the following model to your application:

class Equity < Generalis::Account
  balance_type :credit_normal
end

The balance_type macro defines the behaviour of the balance when credited or debited an amount. The supported modes are:

  • :debit_normal (like Asset or Expense accounts)
  • :credit_normal (like Liability or Revenue accounts)

Alternatively, if you'd prefer to keep naming consistent with the built-in account types, you can instead define your account in an initializer:

module Generalis
  class Equity < Account
    balance_type :credit_normal
  end
end

Ledger Transactions

Ledger transactions are a record of an event or action in the system that impacted the ledger. They are made up of a collection of ledger entries which occurred together.

Writing to the ledger is accomplished by creating a Transaction record, with the associated credit and debit entries applying changes to the balances of their corresponding accounts.

For a Transaction to be valid, the credit and debit entries included in the transaction must balance. This means that the sum of all credits must equal the sum of all debits (per currency). This is a best-effort constraint is enforced by a validation on the transaction model, as well as by marking key attributes on persisted ledger entries as read-only. Generalis is not able to prevent validations from being disabled or removed, nor existing data from being modified directly in the database. For more information, see the data integrity section.

Transactions also store additional information to describe the changes made to the ledger:

Field Type Usage
type String An optional field used for Rails' Single-Table Inheritance functionality.
transaction_id String A unique key for the transaction, intended to prevent duplicate operations, typically human-readable.
description String An optional message describing the event or action that caused this transaction.
occurred_at Time An optional timestamp indicating when the event or action occurred that trigger this transaction.
metadata Hash or Array An optional JSON field used to store application-specific information. Can be used with store_accessor to define custom attributes.

NOTE: In most cases, metadata should not be used to store relationships to other records. Instead, the linked records mechanism should be used.

Transaction DSL

To create a transaction model, run the generator:

$ bin/rails generate generalis:transaction Example

This will generate a new transaction model in your app/models/ledger directory, which contains a stub ledger transaction:

# frozen_string_literal: true

class Ledger::ExampleTransaction < Ledger::BaseTransaction
  transaction_id do
    # TODO: Generate a transaction ID
  end

  description do
    # Optional: Provide a description of the transaction
  end

  occurred_at do
    # Optional: Include a timestamp for the transaction (defaults to now)
  end

  metadata do
    # Optional: Any additional metadata to be stored with the transaction (an Array or Hash)
  end

  double_entry do |e|
    # TODO: Define entries
    # e.debit  = Generalis::Asset[:cash]
    # e.credit = customer.accounts_receivable
    # e.amount = 100.00
  end
end

The transaction_id, description, occurred_at, and metadata DSL macros are used to automatically set their corresponding fields on the constructed Transaction. The functions of these fields is described in the table here.

Transactions behave like ActiveRecord models, so they can be built and saved as you would any other model in your application:

transaction = Ledger::ExampleTransaction.new

if transaction.save
  # All good!
else
  puts transaction.errors
end

NOTE: Beware of potential naming collisions between transaction and some built-in ActiveRecord methods. If creating a belongs_to or has_one association to a Transaction, you will need to name the association ledger_transaction or similar to avoid overwriting the built-in methods.

Linked Records

Generalis allows ActiveRecord models to be associated with transaction classes:

class Ledger::ExampleTransaction < Ledger::BaseTransaction
  has_one_linked :charge
end

Linked records are managed through a polymorphic join-table (handled by the Generalis::Link model), so any model can be associated to a transaction without requiring a database migration.

Linked records behave like a standard Rails association, and can be assigned as normal:

transaction = Ledger::ExampleTransaction.new
transaction.charge = Charge.find(...)

# OR

charge = Charge.find(...)
transaction = Ledger::ExampleTransaction.new(charge: charge)

In cases where the name of the association does not match the name of the class, it's possible to specify the class name explicitly:

  has_one_linked :charge, class_name: 'Card::Charge'

Has-many style associations are also supported in the same way:

  has_many_linked :fees

To add inverse associations to your link records, include the Linkable concern:

class Charge < ApplicationRecord
  include Generalis::Linkable
end

This will add an association that allows access to any linked transactions:

charge = Charge.find(...)

charge.linked_ledger_transactions # => [ ... ]

Double-Entry Notation

When using the default double-entry notation, a debit and credit entry are defined together with a shared amount. These two entries are also linked together by a common key (called a pair_id) so that they be retrieved together.

If you are already using MoneyRails and store amounts as Money objects, these can be assigned directly to the constructed entries:

double_entry do |e|
  e.debit  = Generalis::Asset[:cash]
  e.credit = customer.accounts_receivable
  e.amount = charge.amount
end

If your application does not use Money objects, the amount and currency must be specified explicitly. This is done by assigning values to the amount and currency fields on the entry builder:

  e.amount   = 100.00
  e.currency = 'CAD'

If your application stores money as an integer number of cents, the amount_cents field can be assigned instead:

  e.amount_cents = 100_00
  e.currency     = 'CAD'

Regardless of which mechanism is used, Generalis internally will store these amounts as Money objects.

Manual Credit/Debit Notation

Generalis also provides an alternative to the double-entry notation where credit and debit entries may be separately defined in the DSL:

credit do |e|
  e.account = Generalis::Asset[:cash]
  e.amount  = 100.00
end

debit do |e|
  e.account = customer.accounts_receivable
  e.amount  = 100.00
end

When using this notation, debit and credit entries will not be linked together as a pair (although you can still manually assign a pair_id). However, the credited and debited amounts (per currency) must still be equal.

This notation also allows for transactions that have non-equal numbers of credits and debits, provided that their total amounts sum up to be equal. For example:

credit do |e|
  e.account = Generalis::Asset[:cash]
  e.amount  = 90.00
end

credit do |e|
  e.account = Generalis::Asset[:holding]
  e.amount  = 10.00
end

debit do |e|
  e.account = accounts.accounts_receivable
  e.amount  = 100.00
end

This operation would be considered valid, as both the sum of credits and debits equal to $100.00.

Ad-Hoc Transactions

Generalis also supports creating transactions without using the DSL by directly using the built-in Transaction model. We refer to these as ad-hoc transactions, and they can be useful in cases where there is significant branching in transaction logic, or where defining a transaction class is otherwise not possible.

An example ad-hoc transaction might look like:

transaction = Generalis::Transaction.new

transaction.transaction_id = "charge-#{charge.id}"
transaction.description = "Customer #{customer.id} charge for #{charge.amount}"

# Define the credits and debits that are involved in the transaction.
transaction.add_credit(account: Generalis::Asset[:cash], amount: charge.amount)
transaction.add_debit(account: customer.accounts_receivable, amount: charge.amount)

# Add a linked record for future reference.
transaction.add_link(:charge, charge)

transaction.save!

Data Integrity

Generalis includes several mechanisms that are intended to ensure correctness and integrity of the ledger state and balances. These include validations on the Transaction model, attributes being marked read-only on the ledger Entry model, and automatic locking for ledger Accounts included in a Transaction.

However, it should be noted that these are all best-effort mechanisms and they are not able to catch or prevent all efforts to tamper with the data. Validations can be disabled or removed, read-only constraints can be ignored by queries made directly in the database.

For this reason, a number of tools are provided to assist with prevent and catching these issues if they occur.

Verifying Balances

The most important point of integrity that Generalis is concerned with is ensuring that the ledger balances. This is traditionally verified using the Balance Sheet Equation, which often takes the form of something like:

Assets + Expenses = Liabilities + Revenues + Equity

Generalis generalizes this formula to the following constraint:

SUM(Debit-Normal Accounts) - SUM(Credit-Normal Accounts) = 0

This condition can then be verified with the trial_balances helper method:

Generalis.trial_balances # => {"CAD"=>0,"USD"=>0,"EUR"=>0}

Provided that the balance for each currency sums to zero, the ledger balances for that currency. Any non-zero value indicates that the is error in the ledger.

Locating Problematic Transactions

If a balance issue has been identified, it's important to locate which transactions are causing the issue. Generalis provides a scope to locate any transactions which do not themselves balance:

Generalis::Transaction.imbalanced # => [...]

Accounts and Locking

Generalis automatically handles locking accounts involved in a transaction for the purposes of calculating their balances after the transaction. However, it is important to note that these locks are acquired after the ledger entries have been prepared by the DSL.

This means that if the balances of the accounts are used as part of the amount of the ledger entry, there is a potential race condition with other transactions that may modify the balance of that account.

As an example, consider this transaction which exchanges a customer's store credit from CAD to USD:

class Ledger::ExchangeStoreCreditTransaction < Ledger::BaseTransaction
  # ...

  double_entry do |e|
    e.debit  = Generalis::Asset[:cash]
    e.credit = customer.store_credit
    e.amount = customer.store_credit.balance('CAD')
  end

  double_entry do |e|
    e.debit  = customer.store_credit
    e.credit = Generalis::Asset[:cash]
    e.amount = customer.store_credit.balance('CAD').exchange_to('USD')
  end
end

It is possible for another transaction to have modified the balance of the store_credit account between when the transaction was prepared and when the locks would be acquired to calculate its final balances.

One approach to mitigate this is to acquire locks on the involved accounts ahead of time using a before_prepare hook and the lock_for_account_balance helper method:

  before_prepare do
    Generalis::Account.lock_for_account_balance(
      customer.store_credit,
      Generalis::Asset[:cash]
    )
  end

NOTE: To avoid the risk of deadlocks between transactions, it is important to include all accounts involved in a transaction when acquiring locks.

RSpec Matchers

Generalis includes a number of RSpec matchers to help with testing ledger transactions. To use them, add this to your rails_helper.rb file:

require 'generalis/rspec'

Credit/Debit Account Matchers

When testing transactions, it may be helpful to verify that a particular amount has been credited or debited towards a particular account. For this purpose, the credit_account and debit_account matchers:

let(:charge) { create(:charge) }
let(:customer) { charge.customer }

it 'credits the charge amount to the cash account' do
  expect(transaction).to credit_account(:cash).with_amount(charge.amount)
end

it "debits the charge amount to the customer's receivable account" do
  expect(transaction).to debit_account(customer.accounts_receivable).with_amount(charge.amount)
end

Accounts may be specified either by a name (for global accounts) or by an instance of the account object. The amount may be specified by a Money object or by a numeric value and a currency:

  expect(transaction).to credit_account(:cash).with_amount(100.00, 'CAD')

These matchers are currency aware, and will only consider the currency that is specified in the amount.

NOTE: These matchers will sum together all credits or debits that were made towards the same account. It is not necessary for the transaction to be persisted to use this matcher.

Change Balance of Account Matcher

When testing transactions, it may also be useful to set expectations of what the net-change to an account balance will be after any credits or debits have been applied towards the account. To do so, the change_balance_of matcher is provided:

it 'increases the balance of the cash account by the charge amount' do
  expect(transaction).to change_balance_of(:cash).by(charge.amount)
end

it 'does not change the balance of the orders revenue account' do
  expect(transaction).not_to change_balance_of(:orders)
end

Accounts may be specified either by a name (for global accounts) or by an instance of the account object. The amount may be specified by a Money object or by a numeric value and a currency:

  expect(transaction).to change_balance_of(:cash).by(100.00, 'CAD')

This matcher currency aware, and will only consider the currency that is specified in the amount.

Have Balance Matcher

For testing integration between parts of the system and verifying financial flows, the have_balance matcher is recommended.

let(:order) { create(:order) }
let(:customer) { order.customer }

it "adds the total of the order to the customer's receivable balance after checkout" do
  order.checkout!
  expect(customer.accounts_receivable).to have_balance(order.total)
end

Unlike the debit_account and credit_account matchers which validate a transaction, the have_balance matcher tests the balance of a ledger account.

Examples

Examples of the included RSpec matches being used can be found in the integration test-suite directory, here.

Future Features and Wishlist

Generalis can be better! There's features and helpful tools that we want to build in the future but haven't gotten around to yet. Some of them are:

  • Better install process for MoneyRails
  • More documentation for ledger Entry records
  • Transaction revert and error correction tools
  • Rails::Engine for a pluggable API

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/mintyfresh/generalis.