generalis 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +68 -0
  3. data/.gitignore +11 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +46 -0
  6. data/.ruby-version +1 -0
  7. data/.vscode/settings.json +5 -0
  8. data/Gemfile +13 -0
  9. data/Gemfile.lock +134 -0
  10. data/README.md +622 -0
  11. data/Rakefile +12 -0
  12. data/bin/console +15 -0
  13. data/bin/rspec +29 -0
  14. data/bin/rubocop +29 -0
  15. data/bin/setup +8 -0
  16. data/generalis.gemspec +34 -0
  17. data/lib/generalis/account.rb +98 -0
  18. data/lib/generalis/accountable.rb +43 -0
  19. data/lib/generalis/asset.rb +7 -0
  20. data/lib/generalis/config.rb +15 -0
  21. data/lib/generalis/credit.rb +7 -0
  22. data/lib/generalis/debit.rb +7 -0
  23. data/lib/generalis/entry.rb +66 -0
  24. data/lib/generalis/expense.rb +7 -0
  25. data/lib/generalis/liability.rb +7 -0
  26. data/lib/generalis/link.rb +10 -0
  27. data/lib/generalis/linkable.rb +15 -0
  28. data/lib/generalis/revenue.rb +7 -0
  29. data/lib/generalis/rspec.rb +6 -0
  30. data/lib/generalis/transaction/double_entry.rb +37 -0
  31. data/lib/generalis/transaction/dsl.rb +70 -0
  32. data/lib/generalis/transaction/links.rb +29 -0
  33. data/lib/generalis/transaction/preparation.rb +45 -0
  34. data/lib/generalis/transaction.rb +109 -0
  35. data/lib/generalis/version.rb +5 -0
  36. data/lib/generalis.rb +55 -0
  37. data/lib/generators/factory_bot/templates/transactions.rb.erb +7 -0
  38. data/lib/generators/factory_bot/transaction_generator.rb +39 -0
  39. data/lib/generators/generalis/install_generator.rb +19 -0
  40. data/lib/generators/generalis/migrations_generator.rb +45 -0
  41. data/lib/generators/generalis/templates/base_transaction.rb +12 -0
  42. data/lib/generators/generalis/templates/create_ledger_accounts.rb.erb +18 -0
  43. data/lib/generators/generalis/templates/create_ledger_entries.rb.erb +25 -0
  44. data/lib/generators/generalis/templates/create_ledger_links.rb.erb +14 -0
  45. data/lib/generators/generalis/templates/create_ledger_transactions.rb.erb +14 -0
  46. data/lib/generators/generalis/templates/generalis.rb +6 -0
  47. data/lib/generators/generalis/templates/transaction.rb.erb +26 -0
  48. data/lib/generators/generalis/transaction_generator.rb +33 -0
  49. data/lib/generators/rspec/templates/transaction_spec.rb.erb +11 -0
  50. data/lib/generators/rspec/transaction_generator.rb +41 -0
  51. data/lib/rspec/change_balance_of_matcher.rb +32 -0
  52. data/lib/rspec/credit_account_matcher.rb +45 -0
  53. data/lib/rspec/debit_account_matcher.rb +45 -0
  54. data/lib/rspec/have_balance_matcher.rb +27 -0
  55. data/lib/rspec/helpers/format_helper.rb +28 -0
  56. data/lib/rspec/helpers/resolve_account_helper.rb +21 -0
  57. data/lib/rspec/helpers/resolve_amount_helper.rb +21 -0
  58. 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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Asset < Account
5
+ balance_type :debit_normal
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Credit < Entry
5
+ self.coefficient = CREDIT
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Debit < Entry
5
+ self.coefficient = DEBIT
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Expense < Account
5
+ balance_type :debit_normal
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Liability < Account
5
+ balance_type :credit_normal
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ class Revenue < Account
5
+ balance_type :credit_normal
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rspec/change_balance_of_matcher'
4
+ require_relative '../rspec/credit_account_matcher'
5
+ require_relative '../rspec/debit_account_matcher'
6
+ require_relative '../rspec/have_balance_matcher'
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ VERSION = '0.1.0'
5
+ end