generalis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +68 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +46 -0
- data/.ruby-version +1 -0
- data/.vscode/settings.json +5 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +134 -0
- data/README.md +622 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/generalis.gemspec +34 -0
- data/lib/generalis/account.rb +98 -0
- data/lib/generalis/accountable.rb +43 -0
- data/lib/generalis/asset.rb +7 -0
- data/lib/generalis/config.rb +15 -0
- data/lib/generalis/credit.rb +7 -0
- data/lib/generalis/debit.rb +7 -0
- data/lib/generalis/entry.rb +66 -0
- data/lib/generalis/expense.rb +7 -0
- data/lib/generalis/liability.rb +7 -0
- data/lib/generalis/link.rb +10 -0
- data/lib/generalis/linkable.rb +15 -0
- data/lib/generalis/revenue.rb +7 -0
- data/lib/generalis/rspec.rb +6 -0
- data/lib/generalis/transaction/double_entry.rb +37 -0
- data/lib/generalis/transaction/dsl.rb +70 -0
- data/lib/generalis/transaction/links.rb +29 -0
- data/lib/generalis/transaction/preparation.rb +45 -0
- data/lib/generalis/transaction.rb +109 -0
- data/lib/generalis/version.rb +5 -0
- data/lib/generalis.rb +55 -0
- data/lib/generators/factory_bot/templates/transactions.rb.erb +7 -0
- data/lib/generators/factory_bot/transaction_generator.rb +39 -0
- data/lib/generators/generalis/install_generator.rb +19 -0
- data/lib/generators/generalis/migrations_generator.rb +45 -0
- data/lib/generators/generalis/templates/base_transaction.rb +12 -0
- data/lib/generators/generalis/templates/create_ledger_accounts.rb.erb +18 -0
- data/lib/generators/generalis/templates/create_ledger_entries.rb.erb +25 -0
- data/lib/generators/generalis/templates/create_ledger_links.rb.erb +14 -0
- data/lib/generators/generalis/templates/create_ledger_transactions.rb.erb +14 -0
- data/lib/generators/generalis/templates/generalis.rb +6 -0
- data/lib/generators/generalis/templates/transaction.rb.erb +26 -0
- data/lib/generators/generalis/transaction_generator.rb +33 -0
- data/lib/generators/rspec/templates/transaction_spec.rb.erb +11 -0
- data/lib/generators/rspec/transaction_generator.rb +41 -0
- data/lib/rspec/change_balance_of_matcher.rb +32 -0
- data/lib/rspec/credit_account_matcher.rb +45 -0
- data/lib/rspec/debit_account_matcher.rb +45 -0
- data/lib/rspec/have_balance_matcher.rb +27 -0
- data/lib/rspec/helpers/format_helper.rb +28 -0
- data/lib/rspec/helpers/resolve_account_helper.rb +21 -0
- data/lib/rspec/helpers/resolve_amount_helper.rb +21 -0
- metadata +136 -0
data/bin/rubocop
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rubocop' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path('bundle', __dir__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require 'rubygems'
|
27
|
+
require 'bundler/setup'
|
28
|
+
|
29
|
+
load Gem.bin_path('rubocop', 'rubocop')
|
data/bin/setup
ADDED
data/generalis.gemspec
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/generalis/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'generalis'
|
7
|
+
spec.version = Generalis::VERSION
|
8
|
+
spec.authors = ['Minty Fresh']
|
9
|
+
spec.email = ['7896757+mintyfresh@users.noreply.github.com']
|
10
|
+
|
11
|
+
spec.summary = 'General Ledger for Ruby of Rails'
|
12
|
+
spec.description = spec.summary
|
13
|
+
spec.homepage = 'https://github.com/mintyfresh/generalis'
|
14
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 3.0')
|
15
|
+
|
16
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
17
|
+
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
19
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
20
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
21
|
+
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|integration)/}) }
|
26
|
+
end
|
27
|
+
|
28
|
+
spec.bindir = 'exe'
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
30
|
+
spec.require_paths = ['lib']
|
31
|
+
|
32
|
+
spec.add_dependency 'activerecord', '>= 5', '< 7'
|
33
|
+
spec.add_dependency 'money-rails', '~> 1.15'
|
34
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Account < ActiveRecord::Base
|
5
|
+
CREDIT_NORMAL = -1
|
6
|
+
DEBIT_NORMAL = +1
|
7
|
+
|
8
|
+
attr_readonly :type, :coefficient
|
9
|
+
|
10
|
+
belongs_to :owner, optional: true, polymorphic: true
|
11
|
+
|
12
|
+
has_many :entries, dependent: :restrict_with_error, inverse_of: :account
|
13
|
+
has_many :ledger_transactions, through: :entries
|
14
|
+
|
15
|
+
validates :name, presence: true
|
16
|
+
validates :coefficient, inclusion: { in: [CREDIT_NORMAL, DEBIT_NORMAL] }
|
17
|
+
|
18
|
+
# @param name [Symbol, String]
|
19
|
+
# @param owner [ActiveRecord::Base, nil]
|
20
|
+
# @return [Account]
|
21
|
+
def self.define(name, owner: nil)
|
22
|
+
create_or_find_by!(name: name, owner: owner)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @param name [Symbol, String]
|
26
|
+
# @param owner [ActiveRecord::Base, nil]
|
27
|
+
# @return [Account]
|
28
|
+
def self.[](name, owner: nil)
|
29
|
+
find_by!(name: name, owner: owner)
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param name [Symbol, String]
|
33
|
+
# @param owner [ActiveRecord::Base, nil]
|
34
|
+
# @return [Account]
|
35
|
+
def self.lookup(name, owner: nil)
|
36
|
+
find_by!(name: name, owner: owner)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param balance_type [Symbol]
|
40
|
+
# @return [void]
|
41
|
+
def self.balance_type(balance_type)
|
42
|
+
case balance_type
|
43
|
+
when :credit_normal
|
44
|
+
after_initialize(if: :new_record?) { self.coefficient = CREDIT_NORMAL }
|
45
|
+
when :debit_normal
|
46
|
+
after_initialize(if: :new_record?) { self.coefficient = DEBIT_NORMAL }
|
47
|
+
else
|
48
|
+
raise ArgumentError, "Unsupported balance type: #{balance_type.inspect}. " \
|
49
|
+
'(Expected on of :credit_normal or :debit_normal.)'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Acquires a database lock on one or more accounts for balance calculations.
|
54
|
+
# Locks are acquired in a deterministic sequence to prevent deadlocks.
|
55
|
+
#
|
56
|
+
# @param accounts [Array<Account>]
|
57
|
+
# @return [Boolean]
|
58
|
+
def self.lock_for_balance_calculation(accounts)
|
59
|
+
unscoped.where(id: accounts).order(:id).lock(true).ids.present?
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [Boolean]
|
63
|
+
def credit_normal?
|
64
|
+
coefficient == CREDIT_NORMAL
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Boolean]
|
68
|
+
def debit_normal?
|
69
|
+
coefficient == DEBIT_NORMAL
|
70
|
+
end
|
71
|
+
|
72
|
+
# Returns the balance for a given currency on this account.
|
73
|
+
# If no balance is present for the specified currency, 0 is returned.
|
74
|
+
#
|
75
|
+
# @param currency [String]
|
76
|
+
# @param at [Time, nil]
|
77
|
+
# @return [Money]
|
78
|
+
def balance(currency, at: nil)
|
79
|
+
scope = entries.where(currency: currency)
|
80
|
+
scope = scope.at_or_before(at) if at
|
81
|
+
|
82
|
+
scope.last&.balance_after || Money.from_amount(0, currency)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns the latest balances for all currencies on this account.
|
86
|
+
#
|
87
|
+
# @param at [Time, nil]
|
88
|
+
# @return [Hash{String => Money}]
|
89
|
+
def balances(at: nil)
|
90
|
+
scope = entries.group(:currency).select(Entry.arel_table[:id].maximum)
|
91
|
+
scope = scope.at_or_before(at) if at
|
92
|
+
|
93
|
+
entries.where(id: scope)
|
94
|
+
.map { |entry| [entry.currency, entry.balance_after] }
|
95
|
+
.to_h
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
module Accountable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
has_many :ledger_accounts, as: :owner, class_name: 'Generalis::Account', dependent: false, inverse_of: :owner
|
9
|
+
has_many :ledger_entries, through: :ledger_accounts, source: :entries
|
10
|
+
has_many :ledger_transactions, through: :ledger_accounts
|
11
|
+
end
|
12
|
+
|
13
|
+
class_methods do
|
14
|
+
# rubocop:disable Naming/PredicateName
|
15
|
+
def has_asset_account(name, auto_create: true, dependent: :restrict_with_error)
|
16
|
+
has_account(name, class_name: 'Generalis::Asset', auto_create: auto_create, dependent: dependent)
|
17
|
+
end
|
18
|
+
|
19
|
+
def has_expense_account(name, auto_create: true, dependent: :restrict_with_error)
|
20
|
+
has_account(name, class_name: 'Generalis::Expense', auto_create: auto_create, dependent: dependent)
|
21
|
+
end
|
22
|
+
|
23
|
+
def has_liability_account(name, auto_create: true, dependent: :restrict_with_error)
|
24
|
+
has_account(name, class_name: 'Generalis::Liability', auto_create: auto_create, dependent: dependent)
|
25
|
+
end
|
26
|
+
|
27
|
+
def has_revenue_account(name, auto_create: true, dependent: :restrict_with_error)
|
28
|
+
has_account(name, class_name: 'Generalis::Revenue', auto_create: auto_create, dependent: dependent)
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_account(name, class_name:, auto_create: true, dependent: :restrict_with_error)
|
32
|
+
has_one(name, -> { where(name: name) },
|
33
|
+
as: :owner, class_name: class_name, # rubocop:disable Rails/ReflectionClassName
|
34
|
+
dependent: dependent, inverse_of: :owner)
|
35
|
+
|
36
|
+
after_create(:"create_#{name}", if: -> { send(name).nil? }) if auto_create
|
37
|
+
|
38
|
+
scope :"without_#{name}", -> { left_joins(name.to_sym).merge(Generalis::Account.where(id: nil)) }
|
39
|
+
end
|
40
|
+
# rubocop:enable Naming/PredicateName
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module Generalis
|
6
|
+
class Config
|
7
|
+
# @param value [String]
|
8
|
+
attr_writer :table_name_prefix
|
9
|
+
|
10
|
+
# @return [String]
|
11
|
+
def table_name_prefix
|
12
|
+
@table_name_prefix || 'ledger_'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Entry < ActiveRecord::Base
|
5
|
+
CREDIT = -1
|
6
|
+
DEBIT = +1
|
7
|
+
|
8
|
+
attr_readonly :type, :account_id, :transaction_id, :pair_id, :currency, :amount_cents, :coefficient
|
9
|
+
|
10
|
+
belongs_to :account, inverse_of: :entries
|
11
|
+
belongs_to :ledger_transaction, class_name: 'Transaction', foreign_key: :transaction_id, inverse_of: :entries
|
12
|
+
|
13
|
+
validates :currency, presence: true
|
14
|
+
validates :coefficient, inclusion: { in: [CREDIT, DEBIT] }
|
15
|
+
|
16
|
+
with_options with_model_currency: :currency do
|
17
|
+
monetize :amount_cents, numericality: { greater_than_or_equal_to: 0 }
|
18
|
+
monetize :balance_after_cents, allow_nil: true
|
19
|
+
end
|
20
|
+
|
21
|
+
before_create do
|
22
|
+
self.balance_after = account.balance(currency) + net_amount
|
23
|
+
end
|
24
|
+
|
25
|
+
scope :credit, -> { where(coefficient: CREDIT) }
|
26
|
+
scope :debit, -> { where(coefficient: DEBIT) }
|
27
|
+
|
28
|
+
scope :before, -> (entry) { where(arel_table[:id].lt(entry.id)) }
|
29
|
+
scope :after, -> (entry) { where(arel_table[:id].gt(entry.id)) }
|
30
|
+
|
31
|
+
scope :at_or_before, -> (time) { joins(:transaction).merge(Transaction.at_or_before(time)) }
|
32
|
+
|
33
|
+
# @param value [Integer]
|
34
|
+
# @return [void]
|
35
|
+
def self.coefficient=(coefficient)
|
36
|
+
after_initialize(if: :new_record?) { self.coefficient = coefficient }
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Boolean]
|
40
|
+
def credit?
|
41
|
+
coefficient == CREDIT
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Boolean]
|
45
|
+
def debit?
|
46
|
+
coefficient == DEBIT
|
47
|
+
end
|
48
|
+
|
49
|
+
# The net change in balance that is applied to the account.
|
50
|
+
#
|
51
|
+
# @return [Money]
|
52
|
+
def net_amount
|
53
|
+
amount * coefficient * account.coefficient
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [Boolean]
|
57
|
+
def no_op?
|
58
|
+
amount.zero?
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [Entry, nil]
|
62
|
+
def opposite
|
63
|
+
ledger_transaction.entries.find_by(pair_id: pair_id, coefficient: -coefficient)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Link < ActiveRecord::Base
|
5
|
+
belongs_to :ledger_transaction, class_name: 'Transaction', foreign_key: :transaction_id, inverse_of: :links
|
6
|
+
belongs_to :linkable, polymorphic: true
|
7
|
+
|
8
|
+
validates :name, presence: true
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
module Linkable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
has_many :ledger_links, as: :linkable, class_name: 'Generalis::Link',
|
9
|
+
dependent: :restrict_with_error, inverse_of: :linkable
|
10
|
+
|
11
|
+
has_many :linked_ledger_transactions, class_name: 'Generalis::Transaction',
|
12
|
+
through: :ledger_links, source: :ledger_transaction
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Transaction < ActiveRecord::Base
|
5
|
+
DoubleEntry = Struct.new(:credit, :debit, :amount_cents, :currency) do
|
6
|
+
def amount
|
7
|
+
Money.from_cents(amount_cents || 0, currency)
|
8
|
+
end
|
9
|
+
|
10
|
+
def amount=(value)
|
11
|
+
value = Money.from_amount(value, currency) unless value.is_a?(Money)
|
12
|
+
|
13
|
+
self.amount_cents = value.cents
|
14
|
+
self.currency = value.currency.iso_code
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [Array<Entry>]
|
18
|
+
def entries
|
19
|
+
credit, debit = self.credit, self.debit
|
20
|
+
|
21
|
+
# Reverse the debit/credit accounts when supplied a negative amount.
|
22
|
+
credit, debit = debit, credit if amount.negative?
|
23
|
+
|
24
|
+
[
|
25
|
+
# Flip the amount back to positive since we've already swapped accounts.
|
26
|
+
Credit.new(account: credit, amount: amount.abs, pair_id: pair_id),
|
27
|
+
Debit.new(account: debit, amount: amount.abs, pair_id: pair_id)
|
28
|
+
]
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [String]
|
32
|
+
def pair_id
|
33
|
+
@pair_id ||= SecureRandom.uuid
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'double_entry'
|
4
|
+
require_relative 'preparation'
|
5
|
+
|
6
|
+
module Generalis
|
7
|
+
class Transaction < ActiveRecord::Base
|
8
|
+
module DSL
|
9
|
+
def self.extended(klass)
|
10
|
+
super(klass)
|
11
|
+
|
12
|
+
klass.include(Preparation)
|
13
|
+
klass.before_validation(:prepare, if: :new_record?)
|
14
|
+
end
|
15
|
+
|
16
|
+
def transaction_id(&block)
|
17
|
+
prepare_with do
|
18
|
+
self.transaction_id = instance_exec(&block)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def description(&block)
|
23
|
+
prepare_with do
|
24
|
+
self.description = instance_exec(&block)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def metadata(&block)
|
29
|
+
prepare_with do
|
30
|
+
self.metadata = instance_exec(&block)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def occurred_at(&block)
|
35
|
+
prepare_with do
|
36
|
+
self.occurred_at = instance_exec(&block)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [void]
|
41
|
+
def credit(&block)
|
42
|
+
prepare_with do
|
43
|
+
credit = Credit.new
|
44
|
+
instance_exec(credit, &block)
|
45
|
+
|
46
|
+
entries << credit
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [void]
|
51
|
+
def debit(&block)
|
52
|
+
prepare_with do
|
53
|
+
debit = Debit.new
|
54
|
+
instance_exec(debit, &block)
|
55
|
+
|
56
|
+
entries << debit
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def double_entry(&block)
|
61
|
+
prepare_with do
|
62
|
+
pair = DoubleEntry.new
|
63
|
+
instance_exec(pair, &block)
|
64
|
+
|
65
|
+
entries << pair.entries
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Transaction < ActiveRecord::Base
|
5
|
+
module Links
|
6
|
+
# @param name [Symbol]
|
7
|
+
# @param class_name [String]
|
8
|
+
# @return [void]
|
9
|
+
def has_one_linked(name, class_name: name.to_s.classify) # rubocop:disable Naming/PredicateName
|
10
|
+
has_one :"#{name}_link", -> { where(name: name) },
|
11
|
+
class_name: 'Generalis::Link', dependent: :destroy,
|
12
|
+
foreign_key: :transaction_id, inverse_of: :ledger_transaction
|
13
|
+
|
14
|
+
has_one name, through: :"#{name}_link", source: :linkable, source_type: class_name
|
15
|
+
end
|
16
|
+
|
17
|
+
# @param name [Symbol]
|
18
|
+
# @param class_name [String]
|
19
|
+
# @return [void]
|
20
|
+
def has_many_linked(name, class_name: name.to_s.singularize.classify) # rubocop:disable Naming/PredicateName
|
21
|
+
has_many :"#{name}_links", -> { where(name: name) },
|
22
|
+
class_name: 'Generalis::Link', dependent: :destroy,
|
23
|
+
foreign_key: :transaction_id, inverse_of: :ledger_transaction
|
24
|
+
|
25
|
+
has_many name, through: :"#{name}_links", source: :linkable, source_type: class_name
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Transaction < ActiveRecord::Base
|
5
|
+
module Preparation
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def preparations
|
10
|
+
@preparations ||= []
|
11
|
+
end
|
12
|
+
|
13
|
+
def prepare_with(&block)
|
14
|
+
preparations << block
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
included do
|
19
|
+
define_model_callbacks :prepare
|
20
|
+
end
|
21
|
+
|
22
|
+
# Runs a one-time setup action for the transaction.
|
23
|
+
#
|
24
|
+
# @return [Boolean]
|
25
|
+
def prepare
|
26
|
+
return true if prepared?
|
27
|
+
|
28
|
+
@prepared = true
|
29
|
+
|
30
|
+
run_callbacks(:prepare) do
|
31
|
+
self.class.preparations.each do |preparation|
|
32
|
+
instance_exec(&preparation)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
@prepared
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Boolean]
|
40
|
+
def prepared?
|
41
|
+
persisted? || @prepared
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Generalis
|
4
|
+
class Transaction < ActiveRecord::Base
|
5
|
+
autoload :DSL, 'generalis/transaction/dsl'
|
6
|
+
autoload :Links, 'generalis/transaction/links'
|
7
|
+
|
8
|
+
has_many :entries, dependent: :destroy, inverse_of: :ledger_transaction
|
9
|
+
has_many :accounts, through: :entries
|
10
|
+
|
11
|
+
has_many :links, dependent: :destroy, inverse_of: :ledger_transaction do
|
12
|
+
def [](name)
|
13
|
+
if name.is_a?(Symbol) || name.is_a?(String)
|
14
|
+
find_by(name: name.to_s)
|
15
|
+
else
|
16
|
+
super
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
validates :transaction_id, presence: true, uniqueness: { on: :create }
|
22
|
+
validates :entries, presence: true
|
23
|
+
|
24
|
+
validate on: :create do
|
25
|
+
errors.add(:base, :trial_balance_nonzero) if credit_amounts != debit_amounts
|
26
|
+
end
|
27
|
+
|
28
|
+
before_create do
|
29
|
+
# Acquire locks on all participating accounts to calculate their balance.
|
30
|
+
# Locks are acquired in a deterministic sequence to prevent deadlocks.
|
31
|
+
Account.lock_for_balance_calculation(entries.map(&:account).reject(&:new_record?))
|
32
|
+
end
|
33
|
+
|
34
|
+
scope :at_or_before, -> (time) { where(occurred_at: ..time) }
|
35
|
+
|
36
|
+
scope :with_account, lambda { |account|
|
37
|
+
entries_on_account = Entry.where(account: account)
|
38
|
+
.where(Entry.arel_table[:transaction_id].eq(arel_table[:id]))
|
39
|
+
|
40
|
+
where(entries_on_account.arel.exists)
|
41
|
+
}
|
42
|
+
|
43
|
+
scope :with_currency, lambda { |currency|
|
44
|
+
entries_in_currency = Entry.where(currency: currency)
|
45
|
+
.where(Entry.arel_table[:transaction_id].eq(arel_table[:id]))
|
46
|
+
|
47
|
+
where(entries_in_currency.arel.exists)
|
48
|
+
}
|
49
|
+
|
50
|
+
scope :imbalanced, lambda {
|
51
|
+
subquery = joins(:entries).group(:id, Entry.arel_table[:currency]).having(
|
52
|
+
(Entry.arel_table[:amount_cents] * Entry.arel_table[:coefficient]).sum.not_eq(0)
|
53
|
+
)
|
54
|
+
|
55
|
+
where(id: subquery.select(:id))
|
56
|
+
}
|
57
|
+
|
58
|
+
# @param attributes [Hash]
|
59
|
+
# @return [void]
|
60
|
+
def add_credit(attributes)
|
61
|
+
raise 'Cannot modify persisted transactions' if persisted?
|
62
|
+
|
63
|
+
entries << Credit.new(attributes)
|
64
|
+
end
|
65
|
+
|
66
|
+
# @param attributes [Hash]
|
67
|
+
# @return [void]
|
68
|
+
def add_debit(attributes)
|
69
|
+
raise 'Cannot modify persisted transactions' if persisted?
|
70
|
+
|
71
|
+
entries << Debit.new(attributes)
|
72
|
+
end
|
73
|
+
|
74
|
+
# @param credit_attributes [Hash]
|
75
|
+
# @param debit_attributes [Hash]
|
76
|
+
# @return [void]
|
77
|
+
def add_double_entry(credit_attributes, debit_attributes)
|
78
|
+
pair_id = SecureRandom.uuid
|
79
|
+
|
80
|
+
add_credit(credit_attributes.merge(pair_id: pair_id))
|
81
|
+
add_debit(debit_attributes.merge(pair_id: pair_id))
|
82
|
+
end
|
83
|
+
|
84
|
+
# @param name [Symbol, String]
|
85
|
+
# @param record [ActiveRecord::Base]
|
86
|
+
# @return [void]
|
87
|
+
def add_link(name, record)
|
88
|
+
links << Link.new(name: name, linkable: record)
|
89
|
+
end
|
90
|
+
|
91
|
+
# @return [Hash{String => Money}]
|
92
|
+
def credit_amounts
|
93
|
+
entries.select(&:credit?).group_by(&:currency).transform_values { |entries| entries.sum(&:amount) }
|
94
|
+
end
|
95
|
+
|
96
|
+
# @return [Hash{String => Money}]
|
97
|
+
def debit_amounts
|
98
|
+
entries.select(&:debit?).group_by(&:currency).transform_values { |entries| entries.sum(&:amount) }
|
99
|
+
end
|
100
|
+
|
101
|
+
# Checks whether the transaction is would have any affect on account balances.
|
102
|
+
# A no-op transaction is one that has no entries with non-zero amounts.
|
103
|
+
#
|
104
|
+
# @return [Boolean]
|
105
|
+
def no_op?
|
106
|
+
entries.all?(&:no_op?)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|