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/lib/generalis.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support/core_ext'
5
+
6
+ require_relative 'generalis/config'
7
+ require_relative 'generalis/version'
8
+
9
+ module Generalis
10
+ autoload :Account, 'generalis/account'
11
+ autoload :Accountable, 'generalis/accountable'
12
+ autoload :Asset, 'generalis/asset'
13
+ autoload :Expense, 'generalis/expense'
14
+ autoload :Liability, 'generalis/liability'
15
+ autoload :Revenue, 'generalis/revenue'
16
+
17
+ autoload :Transaction, 'generalis/transaction'
18
+ autoload :Link, 'generalis/link'
19
+ autoload :Linkable, 'generalis/linkable'
20
+
21
+ autoload :Entry, 'generalis/entry'
22
+ autoload :Credit, 'generalis/credit'
23
+ autoload :Debit, 'generalis/debit'
24
+
25
+ # @return [Hash{String => Integer}]
26
+ def self.trial_balances
27
+ subquery = Entry
28
+ .group(:account_id, :currency)
29
+ .select(Entry.arel_table[:id].maximum)
30
+
31
+ Entry
32
+ .joins(:account)
33
+ .where(id: subquery)
34
+ .group(:currency)
35
+ .sum((Entry.arel_table[:balance_after_cents] * Account.arel_table[:coefficient]))
36
+ end
37
+
38
+ # @return [Config]
39
+ def self.config
40
+ @config ||= Config.new.freeze
41
+ end
42
+
43
+ # @return [void]
44
+ def self.configure
45
+ config = Config.new
46
+ yield(config)
47
+
48
+ @config = config.freeze
49
+ end
50
+
51
+ # @return [String]
52
+ def self.table_name_prefix
53
+ config.table_name_prefix
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :<%= factory_name %>, class: '<%= qualified_class_name %>' do
5
+ type { '<%= qualified_class_name %>' }
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module FactoryBot
6
+ module Generators
7
+ class TransactionGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def create_transaction_factory
11
+ template 'transactions.rb.erb', "spec/factories/#{module_path}/#{file_name}.rb"
12
+ end
13
+
14
+ def file_name
15
+ class_name.underscore.pluralize
16
+ end
17
+
18
+ def qualified_class_name
19
+ "#{module_name}::#{class_name}"
20
+ end
21
+
22
+ def class_name
23
+ "#{name.to_s.classify.chomp('Transaction')}Transaction"
24
+ end
25
+
26
+ def module_name
27
+ module_path.classify
28
+ end
29
+
30
+ def module_path
31
+ 'ledger'
32
+ end
33
+
34
+ def factory_name
35
+ class_name.underscore
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Generalis
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def create_initializer
11
+ template 'generalis.rb', 'config/initializers/generalis.rb'
12
+ end
13
+
14
+ def create_base_transaction
15
+ template 'base_transaction.rb', 'app/models/ledger/base_transaction.rb'
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Generalis
7
+ module Generators
8
+ class MigrationsGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ def self.next_migration_number(dir)
14
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
15
+ end
16
+
17
+ def create_migration_files
18
+ migration_template 'create_ledger_accounts.rb.erb', 'db/migrate/create_ledger_accounts.rb'
19
+ migration_template 'create_ledger_transactions.rb.erb', 'db/migrate/create_ledger_transactions.rb'
20
+ migration_template 'create_ledger_entries.rb.erb', 'db/migrate/create_ledger_entries.rb'
21
+ migration_template 'create_ledger_links.rb.erb', 'db/migrate/create_ledger_links.rb'
22
+ end
23
+
24
+ def json_column_type
25
+ case ActiveRecord::Base.connection.adapter_name
26
+ when 'SQLite' then ':string'
27
+ when 'MySQL' then ':json'
28
+ when 'PostgreSQL' then ':jsonb'
29
+ else
30
+ Rails.logger.warn('Unsupported database adapter; using String for JSON data types')
31
+
32
+ ':string'
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # @param name [Symbol, String]
39
+ # @return [String]
40
+ def prefixed_table_name(name)
41
+ ":#{Generalis.table_name_prefix}#{name}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ledger
4
+ class BaseTransaction < Generalis::Transaction
5
+ extend DSL
6
+ extend Links
7
+
8
+ self.abstract_class = true
9
+
10
+ validates :type, presence: true
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLedgerAccounts < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table <%= prefixed_table_name(:accounts) %> do |t|
6
+ t.string :type, null: false
7
+ t.belongs_to :owner, null: true, polymorphic: true
8
+ t.string :name, null: false
9
+ t.integer :coefficient, null: false
10
+ t.timestamps default: -> { 'NOW()' }
11
+
12
+ t.check_constraint 'coefficient IN (-1, +1)'
13
+
14
+ t.index %i[owner_type owner_id name], unique: true
15
+ t.index :name, unique: true, where: 'owner_id IS NULL'
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLedgerEntries < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table <%= prefixed_table_name(:entries) %> do |t|
6
+ t.string :type, null: false
7
+ t.belongs_to :account, null: false, foreign_key: { to_table: <%= prefixed_table_name(:accounts) %> }
8
+ t.belongs_to :transaction, null: false, foreign_key: { to_table: <%= prefixed_table_name(:transactions) %> }
9
+ t.uuid :pair_id, null: true
10
+ t.string :currency, null: false
11
+ t.integer :amount_cents, null: false
12
+ t.integer :balance_after_cents, null: false
13
+ t.integer :coefficient, null: false
14
+ t.column :metadata, <%= json_column_type %>
15
+ t.timestamps default: -> { 'NOW()' }
16
+
17
+ t.check_constraint 'amount_cents >= 0'
18
+ t.check_constraint 'coefficient IN (-1, +1)'
19
+
20
+ t.index %i[transaction_id pair_id]
21
+ # Index for efficiently selecting the latest balance on the account.
22
+ t.index %i[account_id currency id], order: { id: :desc }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLedgerLinks < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table <%= prefixed_table_name(:links) %> do |t|
6
+ t.belongs_to :transaction, null: false, foreign_key: { on_delete: :cascade, to_table: <%= prefixed_table_name(:transactions) %> }
7
+ t.belongs_to :linkable, polymorphic: true, null: false
8
+ t.string :name, null: false
9
+ t.timestamps default: -> { 'NOW()' }
10
+
11
+ t.index %i[transaction_id name]
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLedgerTransactions < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table <%= prefixed_table_name(:transactions) %> do |t|
6
+ t.string :type
7
+ t.string :transaction_id, null: false, index: { unique: true }
8
+ t.string :description
9
+ t.column :metadata, <%= json_column_type %>
10
+ t.timestamp :occurred_at, null: false, default: -> { 'NOW()' }
11
+ t.timestamps default: -> { 'NOW()' }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Generalis.configure do |config|
4
+ # Set a custom database table name prefix for Generalis models.
5
+ # config.table_name_prefix = 'ledger_'
6
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= module_name %>::<%= class_name %> < Ledger::BaseTransaction
4
+ transaction_id do
5
+ # TODO: Generate a transaction ID
6
+ end
7
+
8
+ description do
9
+ # Optional: Provide a description of the transaction
10
+ end
11
+
12
+ occurred_at do
13
+ # Optional: Include a timestamp for the transaction (defaults to now)
14
+ end
15
+
16
+ metadata do
17
+ # Optional: Any additional metadata to be stored with the transaction (an Array or Hash)
18
+ end
19
+
20
+ double_entry do |e|
21
+ # TODO: Define entries
22
+ # e.debit = Generalis::Asset[:cash]
23
+ # e.credit = customer.accounts_receivable
24
+ # e.amount = 100.00
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Generalis
6
+ module Generators
7
+ class TransactionGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def create_transaction
11
+ template 'transaction.rb.erb', "app/models/#{module_path}/#{file_name}.rb"
12
+ end
13
+
14
+ def file_name
15
+ class_name.underscore
16
+ end
17
+
18
+ def class_name
19
+ "#{name.to_s.classify.chomp('Transaction')}Transaction"
20
+ end
21
+
22
+ def module_name
23
+ module_path.classify
24
+ end
25
+
26
+ def module_path
27
+ 'ledger'
28
+ end
29
+
30
+ hook_for :test_framework
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe <%= qualified_class_name %>, type: :model do
6
+ subject(:transaction) { build(:<%= factory_name %>) }
7
+
8
+ it 'has a valid factory' do
9
+ expect(transaction).to be_valid
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Rspec
6
+ module Generators
7
+ class TransactionGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ def create_transaction_spec
11
+ template 'transaction_spec.rb.erb', "spec/models/#{module_path}/#{file_name}.rb"
12
+ end
13
+
14
+ def file_name
15
+ "#{class_name.underscore}_spec"
16
+ end
17
+
18
+ def qualified_class_name
19
+ "#{module_name}::#{class_name}"
20
+ end
21
+
22
+ def class_name
23
+ "#{name.to_s.classify.chomp('Transaction')}Transaction"
24
+ end
25
+
26
+ def module_name
27
+ module_path.classify
28
+ end
29
+
30
+ def module_path
31
+ 'ledger'
32
+ end
33
+
34
+ def factory_name
35
+ class_name.underscore
36
+ end
37
+
38
+ hook_for :fixture_replacement
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/resolve_account_helper'
4
+ require_relative 'helpers/resolve_amount_helper'
5
+
6
+ RSpec::Matchers.define :change_balance_of do |account, owner: nil|
7
+ include Generalis::RSpec::ResolveAccountHelper
8
+ include Generalis::RSpec::ResolveAmountHelper
9
+
10
+ match do |transaction|
11
+ transaction.prepare
12
+
13
+ account = resolve_account(account, owner: owner)
14
+ entries = transaction.entries.select { |entry| entry.account == account }
15
+
16
+ entries.any? && matches_amount?(entries)
17
+ end
18
+
19
+ chain(:by) do |amount, currency = nil|
20
+ @amount = resolve_amount(amount, currency)
21
+ end
22
+
23
+ # @param entries [Array<Generalis::Entry>]
24
+ # @return [Boolean]
25
+ def matches_amount?(entries)
26
+ return entries.group_by(&:currency).values.any? { |bucket| bucket.sum(&:net_amount).nonzero? } if @amount.nil?
27
+
28
+ entries
29
+ .select { |entry| entry.currency.casecmp(@amount.currency).zero? }
30
+ .sum(&:net_amount) == @amount
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/format_helper'
4
+ require_relative 'helpers/resolve_account_helper'
5
+ require_relative 'helpers/resolve_amount_helper'
6
+
7
+ RSpec::Matchers.define :credit_account do |account, owner: nil|
8
+ include Generalis::RSpec::FormatHelper
9
+ include Generalis::RSpec::ResolveAccountHelper
10
+ include Generalis::RSpec::ResolveAmountHelper
11
+
12
+ match do |transaction|
13
+ transaction.prepare
14
+
15
+ @account = resolve_account(account, owner: owner)
16
+ entries = transaction.entries.select { |entry| entry.credit? && entry.account == @account }
17
+
18
+ entries.any? && matches_amount?(entries)
19
+ end
20
+
21
+ chain(:with_amount) do |amount, currency = nil|
22
+ @amount = resolve_amount(amount, currency)
23
+ end
24
+
25
+ failure_message do |transaction|
26
+ message = "expected transaction to credit account #{format_account(@account)}"
27
+ message += " with amount #{format_money(@amount)}" if @amount
28
+ message += "\n"
29
+ message += "\nCredits:\n"
30
+ message += transaction.entries.select(&:credit?).map { |entry| format_entry(entry) }.join("\n")
31
+ message += "\nDebits:\n"
32
+ message += transaction.entries.select(&:debit?).map { |entry| format_entry(entry) }.join("\n")
33
+ message
34
+ end
35
+
36
+ # @param entries [Array<Generalis::Entry>]
37
+ # @return [Boolean]
38
+ def matches_amount?(entries)
39
+ return true if @amount.nil?
40
+
41
+ entries
42
+ .select { |entry| entry.currency.casecmp(@amount.currency).zero? }
43
+ .sum(&:amount) == @amount
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/format_helper'
4
+ require_relative 'helpers/resolve_account_helper'
5
+ require_relative 'helpers/resolve_amount_helper'
6
+
7
+ RSpec::Matchers.define :debit_account do |account, owner: nil|
8
+ include Generalis::RSpec::FormatHelper
9
+ include Generalis::RSpec::ResolveAccountHelper
10
+ include Generalis::RSpec::ResolveAmountHelper
11
+
12
+ match do |transaction|
13
+ transaction.prepare
14
+
15
+ @account = resolve_account(account, owner: owner)
16
+ entries = transaction.entries.select { |entry| entry.debit? && entry.account == @account }
17
+
18
+ entries.any? && matches_amount?(entries)
19
+ end
20
+
21
+ chain(:with_amount) do |amount, currency = nil|
22
+ @amount = resolve_amount(amount, currency)
23
+ end
24
+
25
+ failure_message do |transaction|
26
+ message = "expected transaction to debit account #{format_account(@account)}"
27
+ message += " with amount #{format_money(@amount)}" if @amount
28
+ message += "\n"
29
+ message += "\nCredits:\n"
30
+ message += transaction.entries.select(&:credit?).map { |entry| format_entry(entry) }.join("\n")
31
+ message += "\nDebits:\n"
32
+ message += transaction.entries.select(&:debit?).map { |entry| format_entry(entry) }.join("\n")
33
+ message
34
+ end
35
+
36
+ # @param entries [Array<Generalis::Entry>]
37
+ # @return [Boolean]
38
+ def matches_amount?(entries)
39
+ return true if @amount.nil?
40
+
41
+ entries
42
+ .select { |entry| entry.currency.casecmp(@amount.currency).zero? }
43
+ .sum(&:amount) == @amount
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/format_helper'
4
+ require_relative 'helpers/resolve_account_helper'
5
+ require_relative 'helpers/resolve_amount_helper'
6
+
7
+ RSpec::Matchers.define :have_balance do |amount, currency = nil|
8
+ include Generalis::RSpec::FormatHelper
9
+ include Generalis::RSpec::ResolveAccountHelper
10
+ include Generalis::RSpec::ResolveAmountHelper
11
+
12
+ match do |account, owner: nil|
13
+ @account = resolve_account(account, owner: owner)
14
+ @amount = resolve_amount(amount, currency)
15
+
16
+ values_match?(@account.balance(@amount.currency.to_s), @amount)
17
+ end
18
+
19
+ failure_message do
20
+ message = "expected #{format_account(@account)} to have balance #{format_money(@amount)}\n"
21
+ message + "\tactual balance was #{format_money(@account.balance(@amount.currency.to_s))}"
22
+ end
23
+
24
+ failure_message_when_negated do
25
+ "expected #{format_account(@account)} not to have balance #{format_money(@amount)}"
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ module RSpec
5
+ module FormatHelper
6
+ # @param entry [Generalis::Entry]
7
+ # @return [String]
8
+ def format_entry(entry)
9
+ "\t#{format_money(entry.amount)} to #{format_account(entry.account)}"
10
+ end
11
+
12
+ # @param account [Generalis::Account]
13
+ # @return [String]
14
+ def format_account(account)
15
+ text = "#{account.class}[:#{account.name}]"
16
+ text += " (Owner: #{account.owner.class} #{account.owner.id})" if account.owner
17
+
18
+ text
19
+ end
20
+
21
+ # @param money [Money]
22
+ # @return [String]
23
+ def format_money(money)
24
+ "#{money.format} (#{money.currency})"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ module RSpec
5
+ module ResolveAccountHelper
6
+ # @param location [Symbol, String, Generalis::Account]
7
+ # @param owner [ActiveRecord::Base, nil]
8
+ # @return [Generalis::Account]
9
+ def resolve_account(locator, owner:)
10
+ case locator
11
+ when Generalis::Account
12
+ locator
13
+ when String, Symbol
14
+ Generalis::Account.lookup(locator, owner: owner)
15
+ else
16
+ raise ArgumentError, "Expected a Generalis::Account, String, or Symbol, got #{account.inspect}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Generalis
4
+ module RSpec
5
+ module ResolveAmountHelper
6
+ # @param amount [Money, Numeric]
7
+ # @param currency [String, nil]
8
+ # @return [Money]
9
+ def resolve_amount(amount, currency = nil)
10
+ case amount
11
+ when Money
12
+ amount
13
+ when Numeric
14
+ Money.from_amount(amount, currency)
15
+ else
16
+ raise ArgumentError, "Expected Money or Numeric, got #{amount.inspect}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end