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