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