A double-entry accounting system for Rails applications
Add to your Gemfile:
gem "ledgerizer"bundle installrails g ledgerizer:installLuego de correr el instalador, se debe definir (usando el DSL de definición): tenants, accounts y entries. El formato es el siguiente:
Ledgerizer.setup do |conf|
conf.tenant(:tenant_name1) do
conf.asset :account_name1
conf.asset :account_name2
conf.liability :account_name3
conf.liability :account_name4
conf.liability :account_name5
conf.equity :account_name6
conf.income :account_name7
conf.expense :account_name8
conf.equity :account_name9
# more accounts...
conf.entry :entry_code1, document: :document1 do
conf.debit account: :account_name1, accountable: :accountable1
conf.credit account: :account_name4, accountable: :accountable2
end
conf.entry :entry_code2, document: :document2 do
conf.debit account: :account_name4, accountable: :accountable2
conf.credit account: :account_name5, accountable: :accountable1
conf.credit account: :account_name6, accountable: :accountable3
end
# more entries...
end
conf.tenant(:tenant_name2) do
# more definitions...
end
end-
tenant: un negocio puede llevar la contabilidad de distintas entidades. Lostenantrepresentan esas entidades. El nombre de un tenant, debe ser el nombre de un modelo deActiveRecord(o clase de Ruby) que incluye el móduloLedgerizerTenant. -
asset: define una cuenta de este tipo. De forma similar se definen cuentas para representar:liability,equity,incomeyexpense. -
entry: representa un movimiento contable entre 2 o más cuentas. Cada entrada está asociada a undocumentdel negocio. Estedocumentdebe ser un modeloActiveRecord(o clase de Ruby) que incluye el móduloLedgerizerDocument. -
debit/credit: se usan dentro de unaentryy definen hacia qué dirección se mueve el capital. Además, asocian un modelo deActiveRecord(o clase de Ruby) que incluye el móduloLedgerizerAccountablea una cuenta a través de el atributoaccountable.
accountablepuede sernilsi se desea que la cuenta no quede asociada a una entidad específica.
Ledgerizer.setup do |conf|
conf.tenant(:portfolio) do
conf.asset :bank
conf.liability :funds_to_invest
conf.liability :to_invest_in_fund
conf.entry :user_deposit, document: :deposit do
conf.debit account: :bank, accountable: :bank
conf.credit account: :funds_to_invest, accountable: :user
end
conf.entry :user_deposit_distribution, document: :deposit do
conf.debit account: :funds_to_invest, accountable: :user
conf.credit account: :to_invest_in_fund, accountable: :user
end
end
endParte de la definición consiste en incluir los módulos de Ledgerizer en los modelos/clases que corresponda.
- Todos los modelos/clases definidos como
tenant, deben incluir:LedgerizerTenant - Todos los modelos/clases definidos como
document, deben incluir:LedgerizerDocument - Todos los modelos/clases definidos como
accountable, deben incluir:LedgerizerAccountable
Se debe tener en cuenta que un modelo/clase no puede ser usado con dos roles distintos. Por ejemplo: si
Useres unaccountable, no podrá ser usado comodocument.
class Portfolio
include LedgerizerTenant
def id
999 # Es obligatorio usar un id.
end
end
class Bank
include LedgerizerAccountable
def id
666 # Es obligatorio usar un id.
end
end
class User < ApplicationRecord
include LedgerizerAccountable
end
class Deposit < ApplicationRecord
include LedgerizerDocument
endComo pueden ver en el ejemplo, usé clases de
ActiveRecordparaUseryDepositpero paraBankyPortfolioclases normales de Ruby. El uso de una cosa u otra dependerá de la necesidad de la aplicación.
Una vez definidas las entries, podremos crear movimientos en la DB. Para hacer esto, debemos incluir el DSL de ejecución así:
# Suponemos que existen los modelos de ActiveRecord UserDeposit, User y Bank y una clase Ruby (Portfolio) que usaremos de tenant .
class DepositCreator
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(10, 'CLP'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(10, 'CLP'))
end
end
endLa ejecución de DepositCreator.new.perform creará:
- Dos
Ledgerizer::Account
-
Una con
name: 'bank',tenant: Portfolio.new,accountable: Bank.first,account_type: 'asset'ycurrency: 'CLP' -
Otra con
name: 'funds_to_invest',tenant: Portfolio.new,accountable: User.first,account_type: 'liability'ycurrency: 'CLP'
-
Una
Ledgerizer::Entrycon:code: 'user_deposit',tenant: Portfolio.new,document: UserDeposit.firstyentry_time: '1984-06-04' -
Dos
Ledgerizer::Line. Una por cada movimiento de la entry.
-
Una con
entry_id: apuntando a la entry del punto 2,account_id: apuntando a 1.1,amount: 10 CLP -
Una con
entry_id: apuntando a la entry del punto 2,account_id: apuntando a 1.2,amount: 10 CLP
-
Cada
Ledgerizer::Lineademás incluye información desnormalizada para facilitar consultas. Esto es:tenant,document,entry_time,entry_code -
Al ejecutar una entry, se puede dividir el monto en n movmientos siempre y cuando se respete lo que está en la definición para esa entry. Por ej, algo como lo siguiente, sería válido:
class DepositCreator include Ledgerizer::Execution::Dsl def perform execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(10, 'CLP')) credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(6, 'CLP')) credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(3, 'CLP')) credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(1, 'CLP')) end end end
-
Los montos de los movimientos deben estar de acuerdo con https://en.wikipedia.org/wiki/Trial_balance
Siguiendo el ejemplo, supongamos que luego de ejecutar algunas entries, tenemos:
tenant = Portfolio.new
entry = Deposit.first.entries.first
account = User.first.accounts.firstCon esto podemos hacer:
Para tenant
tenant.account_balance(account_name, currency): devuelve el balance de una cuenta. Ejemplo:tenant.account_balance(:bank, "CLP")tenant.account_type_balance(account_type, currency): devuelve el balance de un tipo de cuenta. Ejemplo:tenant.account_type_balance(:asset, "CLP"). Los tipos pueden ser:asset, expense, liability, income, equitytenant.accounts: devuelve todas lasLedgerizer::Accountasociadas al tenanttenant.entries: devuelve todas lasLedgerizer::Entryasociadas al tenanttenant.ledger_lines(filters): devuelve todas lasLedgerizer::Lineasociadas al tenanttenant.ledger_sum(filters): devuelve la suma de todas lasLedgerizer::Lineasociadas al tenant
Para entry
entry.ledger_lines(filters): devuelve todas lasLedgerizer::Lineasociadas a la entryentry.ledger_sum(filters): devuelve la suma de todas lasLedgerizer::Lineasociadas a la entry
Para account
account.balance: devuelve el balance de la cuenta desde cachéaccount.balance_at(date): devuelve el balance de la cuenta desde caché hasta una fechaaccount.ledger_lines(filters): devuelve todas lasLedgerizer::Lineasociadas al accountaccount.ledger_sum(filters): devuelve la suma de todas lasLedgerizer::Lineasociadas al account
Los métodos ledger_lines y ledger_sum aceptan los siguientes filtros:
entries: Array de objetosLedgerizer::Entry. También se puede usarentrypara filtrar por un único objeto.entry_codes: Array decodes definidos en eltenant. En el ejemplo::user_deposityuser_deposit_distribution. También se puede usarentry_codepara filtrar por un único código.accounts: Array de objetosLedgerizer::Account. También se puede usaraccountpara filtrar por una única cuenta.account_names: Array denames de cuentas definidos en eltenant. En el ejemplo::funds_to_investybank. También se puede usaraccount_namepara filtrar por un único nombre de cuenta.account_types: Array de tipos de cuenta. Puede ser:asset,expense,liability,incomeyequity. También se puede usaraccount_typepara filtrar por un único tipo de cuenta.amount[_lt|_lteq|_gt|_gteq]: Para filtrar poramount<, <=, > o >=. Debe ser una instancia deMoneyy si no se usa sufijo (_xxx) se buscará un monto igual.entry_time[_lt|_lteq|_gt|_gteq]: Para filtrar porentry_time<, <=, > o >=. Debe ser una instancia deDateTimey si no se usa sufijo (_xxx) se buscará una fecha/hora igual.
Se debe tener en cuenta que algunos filtros no harán sentido en algunos contextos y por esto serán ignorados. Por ejemplo: si ejecuto
entry.ledger_sum(document: Deposit.last), el filtrodocumentserá ignorado ya que ese filtro saldrá deentry.
-
Saber el balance de cada cuenta de tipo asset hasta el 10 de enero 2019. Para lograr esto, podría hacer:
tenant.accounts.where(account_type: :asset).each do |asset_account| p "#{asset_account.name}: #{asset_account.ledger_sum(entry_time_lteq: '2019-01-10')}" end
-
Saber las líneas que conforman un una entry con código
user_depositpara el día 10 de enero 2019.tenant.ledger_lines(entry_code: :user_deposit, entry_time: '2019-01-10')
Puede resultar útil mostrar en la consola información de accounts, entries o lines. Ejemplos:
Ledgerizer::Account.to_table # para mostrar todas las cuentasID | ACCOUNT_TYPE | CURRENCY | NAME | ACCOUNTABLE_ID | ACCOUNTABLE_TYPE | TENANT_ID | TENANT_TYPE | BALANCE.FORMAT
---|--------------|----------|----------|----------------|------------------|-----------|-------------|---------------
1 | asset | CLP | account1 | 1 | User | 999 | Portfolio | $161
5 | liability | CLP | account2 | 4 | User | 999 | Portfolio | $225
2 | liability | CLP | account2 | 6 | User | 999 | Portfolio | $204
4 | asset | CLP | account1 | 2 | User | 999 | Portfolio | $230
7 | liability | CLP | account2 | 5 | User | 999 | Portfolio | $193
9 | asset | CLP | account1 | 3 | User | 999 | Portfolio | $231
User.first.accounts.to_table # Para mostrar las cuentas de un accountableID | ACCOUNT_TYPE | CURRENCY | NAME | ACCOUNTABLE_ID | ACCOUNTABLE_TYPE | TENANT_ID | TENANT_TYPE | BALANCE.FORMAT
---|--------------|----------|----------|----------------|------------------|-----------|-------------|---------------
1 | asset | CLP | account1 | 1 | User | 999 | Portfolio | $161
Ledgerizer::Account.first.lines.to_table # para mostrar las lines de una cuentaID | ACCOUNT_NAME | ACCOUNTABLE_ID | ACCOUNTABLE_TYPE | ACCOUNT_ID | DOCUMENT_ID | DOCUMENT_TYPE | ACCOUNT_TYPE | ENTRY_CODE | ENTRY_TIME | ENTRY_ID | TENANT_ID | TENANT_TYPE | AMOUNT.FORMAT | BALANCE.FORMAT
----|--------------|----------------|------------------|------------|-------------|---------------|--------------|------------|-------------------------|----------|-----------|-------------|---------------|---------------
381 | account1 | 1 | User | 1 | 252 | Deposit | asset | test | 2020-04-17 22:23:11 | 192 | 999 | Portfolio | $2 | $161
378 | account1 | 1 | User | 1 | 251 | Deposit | asset | test | 2020-04-17 22:23:11 | 191 | 999 | Portfolio | $2 | $159
369 | account1 | 1 | User | 1 | 246 | Deposit | asset | test | 2020-04-17 22:23:11 | 186 | 999 | Portfolio | $1 | $157
357 | account1 | 1 | User | 1 | 241 | Deposit | asset | test | 2020-04-17 22:23:11 | 181 | 999 | Portfolio | $2 | $156
349 | account1 | 1 | User | 1 | 237 | Deposit | asset | test | 2020-04-17 22:23:11 | 177 | 999 | Portfolio | $4 | $154
297 | account1 | 1 | User | 1 | 211 | Deposit | asset | test | 2020-04-17 22:23:11 | 151 | 999 | Portfolio | $5 | $150
...
Ledgerizer::Entry.first.to_table # para mostrar una instancia como tabla.ID | ENTRY_TIME | DOCUMENT_ID | DOCUMENT_TYPE | CODE | TENANT_ID | TENANT_TYPE
---|-------------------------|-------------|---------------|------|-----------|------------
1 | 2020-04-17 22:23:11 | 64 | Deposit | test | 1 | Portfolio
Este mecanismo sirve para corregir errores en entries creadas con anterioridad.
Toda entry que se ejecute con el mismo document y datetime más de una vez, será considerada un ajuste y debido a esto se reemplazarán las lines de la entry previamente guardada.
Siguendo con el ejemplo del DepositCreator...
class DepositCreator
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(10, 'CLP'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(10, 'CLP'))
end
end
endSi lo ejecuto una vez, obtendré las dos líneas que mencioné anteriormente:
-
Una relacionada con la cuenta
bankporamount: 10 CLP -
Una relacionada con la cuenta
funds_to_investporamount: 10 CLP
Hasta aquí es un caso normal. Ahora supongamos que tenenmos la siguiente clase que modifica solo los montos de DepositCreator.
class DepositFixer
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(15, 'CLP'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(15, 'CLP'))
end
end
endAl ejecutar el DepositFixer se borrarán las líneas:
-
La relacionada con la cuenta
bankporamount: 10 CLP -
La relacionada con la
funds_to_investporamount: 10 CLP
y se agregarán 2 nuevas:
-
Una relacionada con la cuenta
bankporamount: 15 CLP -
Una relacionada con la cuenta
funds_to_investporamount: 15 CLP
Las cuentas de ledgerizer se pueden definir para trabajar con más de un tipo de moneda. Si tengo la siguiente definición:
Ledgerizer.setup do |conf|
conf.tenant(:portfolio, currency: :clp) do
conf.asset :bank, currencies: [:usd]
conf.liability :funds_to_invest, currencies: [:usd]
conf.entry :user_deposit, document: :deposit do
conf.debit account: :bank, accountable: :bank
conf.credit account: :funds_to_invest, accountable: :user
end
end
endse podrá ejecutar la entry user_deposit para dos tipos de moneda: la base definida en el tenant (CLP) y la definida en currencies: [] (en este caso USD). Por ejemplo:
class DepositCreator
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(10, 'USD'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(10, 'USD'))
end
end
endy
class DepositCreator
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04") do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(1000, 'CLP'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(1000, 'CLP'))
end
end
endserían dos entradas válidas.
Tener en cuenta:
- Por cada currency, existirá una cuenta. Es decir que si ejecutamos las dos anteriores, tendremos 4 cuentas: 2 en CLP y 2 en USD.
- Siempre las cuentas se crean en la moneda base definida en el tenant. Es decir que si omites la opción
currencies, se asumirá que esa cuenta tiene la misma moneda que el tenant. - Si no se define la opción
currencyen el tenant, se usará la que viene por defecto en la gema Money (Money.default_currency). Es decir, siempre existirá una moneda base.
Opcionalmente en entries que trabajan con cuentas multicurrency se puede especificar un conversion_amount. Al hacer esto, si corremos el siguiente código:
class DepositCreator
include Ledgerizer::Execution::Dsl
def perform
execute_user_deposit_entry(tenant: Portfolio.new, document: UserDeposit.first, datetime: "1984-06-04", conversion_amount: Money.from_amount(600, 'CLP')) do
debit(account: :bank, accountable: Bank.first, amount: Money.from_amount(10, 'USD'))
credit(account: :funds_to_invest, accountable: User.first, amount: Money.from_amount(10, 'USD'))
end
end
endobtendremos 2 entries. La primera contendrá líneas por los 10 USD (monto original) y la segunda por su equivalente en la moneda del tenant (CLP). En este caso, dos líneas de 6000 CLP que es el resultado de multiplicar 10 USD x 600 CLP (valor de conversión).
Cabe destacar que además se generarán cuentas especiales para llevar el balance de estos montos convertidos.
Como vimos anteriormente, Ledgerizer nos permite registrar entries en una currency distinta a la del tenant. Además, permite llevar esos montos en la moneda del tenant haciendo uso de las cuentas espejo. Ejemplo:
La siguiente configuración representa el cobro de una comisión por el intercambio de criptomonedas en un exchange:
Ledgerizer.setup do |conf|
conf.tenant(:portfolio, currency: :clp) do
conf.asset :funds, currencies: [:btc]
conf.income :trade_transaction_fee, currencies: [:btc]
conf.entry :user_trade_fee, document: :bank_movement do
conf.debit account: :funds, accountable: :wallet
conf.credit account: :trade_transaction_fee
end
end
endSi ejecuto la entry que registra el cobro de la comisión así:
execute_user_trade_fee_entry(tenant: Portfolio.new, document: BankMovement.first, datetime: "2020-01-01", conversion_amount: Money.from_amount(9000000, 'CLP')) do
debit(account: :funds, accountable: Wallet.first, amount: Money.from_amount(2, 'BTC'))
credit(account: :trade_transaction_fee, amount: Money.from_amount(2, 'BTC'))
endEn el día 2020-01-01:
- Se registarán los 2 BTC en las cuentas que corresponda.
- Se registrará su equivalente en CLP (2 BTC * 9000000 CLP = 18000000 CLP) en las cuentas espejo.
Supongamos que pasa el tiempo (es 2020-10-01) y el BTC aumenta su valor a 12000000 CLP. Si miramos la cuenta espejo, nos dirá que tenemos 18000000 CLP algo que al día de hoy no es cierto ya que en realidad tenemos 2 BTC * 12000000 CLP = 24000000 CLP. Para lograr que la cuenta espejo refleje esta realidad es que necesitamos del mecanismo de revalorización. Se configura así:
Ledgerizer.setup do |conf|
conf.tenant(:portfolio, currency: :clp) do
conf.asset :funds, currencies: [:btc]
conf.income :trade_transaction_fee, currencies: [:btc]
config.revaluation :crypto_exposure do
conf.account :funds, accountable: :wallet
end
conf.entry :user_trade_fee, document: :bank_movement do
conf.debit account: :funds, accountable: :wallet
conf.credit account: :trade_transaction_fee
end
end
endA config.revaluation se le pasa el nombre que identifica la revalorización. En este caso: :crypto_exposure y dentro, todas aquellas cuentas que necesitan ser revalorizadas. En este caso, el asset llamado funds.
La ejecución es así:
execute_crypto_exposure_revaluation(
tenant: Portfolio.first,
currency: :btc,
datetime: "2020-01-01".to_datetime,
conversion_amount: Money.new(12000000, :clp),
account_name: :funds,
accountable: Wallet.first
)Por debajo este código calculará la diferencia (en la moneda del tenant) entre lo que tenía en la cuenta espejo y lo que debería tener en la actualidad así:
valor_registrado = 18000000 CLP
valor_actual = 2 BTC * conversion_amount (12000000 CLP) = 24000000 CLP
diferencia = valor_actual - valor_registrado (6000000 CLP)
Esta diferencia luego se registrará como un income en una cuenta positive_crypto_exposure_asset_revaluation y también en la cuenta espejo funds (asset)
Tener en cuenta:
-
Si la diferencia resultara ser negativa, se contabilizará en el
expensellamadonegative_crypto_exposure_asset_revaluationy también en la cuenta espejofunds(asset) pero esta vez como un crédito para reducir su valor. -
Las revalorizaciones también pueden hacerse contra cuentas de tipo
liability. -
Es posible revalorizar múltiples cuentas contra una
revaluationasí:Ledgerizer.setup do |conf| conf.tenant(:tenant1, currency: :clp) do conf.asset :account1, currencies: [:btc] conf.asset :account2, currencies: [:btc] config.revaluation :rev1 do conf.account :account1, accountable: :accountable1 conf.account :account2, accountable: :accountable2 end end end
-
Solo pueden revalorizarse cuentas con moneda distinta al tenant. Es decir, que tengan cuenta espejo.
Para poder correr los tests necesitarás agregar al spec/rails_helper.rb lo siguiente:
config.before do
allow_any_instance_of(Ledgerizer::Definition::Config).to receive(
:running_inside_transactional_fixtures
).and_return(true)
endTambién puedes agregar execution_matchers y definition_dsl_matchers que te ayudarán a definir tus tests.
To run the specs you need to execute, in the root path of the gem, the following command:
bundle exec guardYou need to put all your tests in the /ledgerizer/spec/dummy/spec/ directory.
Inspirado en double entry...
Se puede correr el siguiente comando:
bin/jack_hammer -p 5 -e 50Para probar que al ejecutar varias entries, de manera concurrente, todas las líneas y balances se generan correctamente.
- Fork it
- 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 new Pull Request
Thank you contributors!
Ledgerizer is maintained by platanus.
Ledgerizer is © 2019 platanus, spa. It is free software and may be redistributed under the terms specified in the LICENSE file.