generalis 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|