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.
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