├── .yardopts ├── Gemfile ├── spec ├── support │ ├── money.rb │ ├── rspec_its.rb │ ├── line_metadata.rb │ ├── timecop.rb │ ├── logging.rb │ ├── database.ci.yml │ ├── database.example.yml │ ├── gemfiles │ │ ├── Gemfile.rails-6.1.x │ │ ├── Gemfile.rails-7.0.x │ │ └── Gemfile.rails-7.1.x │ ├── performance_helper.rb │ ├── database.rb │ ├── factories.rb │ ├── accounts.rb │ ├── double_entry_spec_helper.rb │ └── schema.rb ├── spec_support.rb ├── double_entry │ ├── account_balance_spec.rb │ ├── configuration_spec.rb │ ├── balance_calculator_spec.rb │ ├── line_spec.rb │ ├── account_spec.rb │ ├── validation │ │ └── line_check_spec.rb │ ├── locking_spec.rb │ └── transfer_spec.rb ├── performance │ └── double_entry_performance_spec.rb ├── active_record │ └── locking_extensions_spec.rb ├── generators │ └── double_entry │ │ └── install │ │ └── install_generator_spec.rb ├── spec_helper.rb └── double_entry_spec.rb ├── .rspec ├── lib ├── double_entry │ ├── version.rb │ ├── validation.rb │ ├── line_metadata.rb │ ├── errors.rb │ ├── configuration.rb │ ├── validation │ │ ├── account_fixer.rb │ │ └── line_check.rb │ ├── configurable.rb │ ├── account_balance.rb │ ├── balance_calculator.rb │ ├── transfer.rb │ ├── account.rb │ ├── line.rb │ └── locking.rb ├── active_record │ ├── locking_extensions │ │ └── log_subscriber.rb │ └── locking_extensions.rb ├── generators │ └── double_entry │ │ └── install │ │ ├── templates │ │ ├── initializer.rb │ │ └── migration.rb │ │ └── install_generator.rb └── double_entry.rb ├── script ├── setup.sh └── jack_hammer ├── .dockerignore ├── Rakefile ├── Dockerfile ├── .gitignore ├── docker-compose.yml ├── LICENSE.md ├── .github └── workflows │ └── ci-workflow.yml ├── double_entry.gemspec ├── README.md └── CHANGELOG.md /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | - LICENSE.md 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /spec/support/money.rb: -------------------------------------------------------------------------------- 1 | Money.locale_backend = :i18n 2 | -------------------------------------------------------------------------------- /spec/support/rspec_its.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/its' 2 | 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --require spec_support 3 | -------------------------------------------------------------------------------- /spec/support/line_metadata.rb: -------------------------------------------------------------------------------- 1 | require 'double_entry/line_metadata' 2 | require 'double_entry/line' 3 | -------------------------------------------------------------------------------- /lib/double_entry/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module DoubleEntry 4 | VERSION = '2.0.1' 5 | end 6 | -------------------------------------------------------------------------------- /lib/double_entry/validation.rb: -------------------------------------------------------------------------------- 1 | require 'double_entry/validation/account_fixer' 2 | require 'double_entry/validation/line_check' 3 | -------------------------------------------------------------------------------- /spec/spec_support.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'double_entry' 3 | 4 | Dir.glob(File.join(__dir__, 'support/**/*.rb')).sort.each { |f| require f } 5 | -------------------------------------------------------------------------------- /spec/support/timecop.rb: -------------------------------------------------------------------------------- 1 | require 'timecop' 2 | 3 | RSpec.configure do |config| 4 | config.after(:example) do 5 | Timecop.return 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /script/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "This command will setup your local dev environment, including" 4 | echo " * bundle install" 5 | echo 6 | 7 | echo "Bundling..." 8 | bundle install --binstubs bin --path .bundle 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.* 4 | !/.rspec 5 | /coverage/ 6 | /Dockerfile 7 | /docker-compose*.yml 8 | /pkg/ 9 | /tmp/ 10 | /profiles/ 11 | /_yardoc/ 12 | /doc/ 13 | /rdoc/ 14 | /Gemfile.lock 15 | /log/ 16 | /spec/examples.txt 17 | /spec/reports/ 18 | /spec/support/database.yml 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | require 'bundler/gem_tasks' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default do 7 | %w(mysql postgres sqlite).each do |db| 8 | puts "Running tests with `DB=#{db}`" 9 | ENV['DB'] = db 10 | Rake::Task['spec'].execute 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/logging.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_support/logger' 3 | 4 | RSpec.configure do |config| 5 | config.before(:suite) do 6 | log_file = File.expand_path('../../../log/test.log', __FILE__) 7 | 8 | FileUtils.mkdir_p(File.dirname(log_file)) 9 | FileUtils.rm(log_file, force: true) 10 | ActiveRecord::Base.logger = ActiveSupport::Logger.new(log_file) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/double_entry/line_metadata.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | class LineMetadata < ActiveRecord::Base 3 | class SymbolWrapper 4 | def self.load(string) 5 | return unless string 6 | string.to_sym 7 | end 8 | 9 | def self.dump(symbol) 10 | return unless symbol 11 | symbol.to_s 12 | end 13 | end 14 | 15 | belongs_to :line 16 | serialize :key, coder: DoubleEntry::LineMetadata::SymbolWrapper 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.5-alpine 2 | 3 | WORKDIR /double_entry 4 | 5 | RUN set -ex; \ 6 | apk add --no-cache \ 7 | build-base \ 8 | git \ 9 | mariadb-dev \ 10 | postgresql-dev \ 11 | sqlite-dev \ 12 | tzdata \ 13 | ; \ 14 | gem update --system 15 | 16 | COPY Gemfile* double_entry.gemspec ./ 17 | COPY lib/double_entry/version.rb ./lib/double_entry/version.rb 18 | RUN bundle install 19 | 20 | COPY . ./ 21 | COPY spec/support/database.example.yml ./spec/support/database.yml 22 | -------------------------------------------------------------------------------- /spec/support/database.ci.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | username: mysql 4 | password: password 5 | database: double_entry_test 6 | pool: 100 7 | timeout: 5000 8 | host: 127.0.0.1 9 | 10 | postgres: 11 | adapter: postgresql 12 | username: postgres 13 | password: password 14 | database: double_entry_test 15 | min_messages: ERROR 16 | pool: 100 17 | timeout: 5000 18 | host: localhost 19 | 20 | sqlite: 21 | adapter: sqlite3 22 | encoding: utf8 23 | database: tmp/double_entry_test.sqlite3 24 | pool: 100 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Performance profiling 13 | /profiles/ 14 | 15 | ## Documentation cache and generated files: 16 | /.yardoc/ 17 | /_yardoc/ 18 | /doc/ 19 | /rdoc/ 20 | 21 | ## Environment normalisation: 22 | /.bundle/ 23 | /lib/bundler/man/ 24 | /Gemfile.lock 25 | /.ruby-version 26 | /.ruby-gemset 27 | 28 | # DoubleEntry specific 29 | /bin/ 30 | /log/ 31 | /spec/examples.txt 32 | /spec/reports/ 33 | /spec/support/database.yml 34 | -------------------------------------------------------------------------------- /spec/support/database.example.yml: -------------------------------------------------------------------------------- 1 | postgres: 2 | host: <%= ENV.fetch('POSTGRES_HOST') { 'localhost' } %> 3 | adapter: postgresql 4 | encoding: unicode 5 | database: double_entry_test 6 | pool: 100 7 | username: postgres 8 | password: 9 | min_messages: warning 10 | mysql: 11 | host: <%= ENV.fetch('MYSQL_HOST') { '127.0.0.1' } %> 12 | adapter: mysql2 13 | encoding: utf8 14 | database: double_entry_test 15 | pool: 100 16 | username: root 17 | password: 18 | sqlite: 19 | adapter: sqlite3 20 | encoding: utf8 21 | database: tmp/double_entry_test.sqlite3 22 | pool: 100 23 | -------------------------------------------------------------------------------- /spec/support/gemfiles/Gemfile.rails-6.1.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../../../' 4 | 5 | gem 'activerecord', '~> 6.1.0' 6 | 7 | # Rails imposed database gem constraints 8 | gem 'mysql2', '~> 0.5' # https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 9 | gem 'pg', '~> 1.1' # https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L3 10 | gem 'sqlite3', '~> 1.4' # https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L14 11 | -------------------------------------------------------------------------------- /spec/support/gemfiles/Gemfile.rails-7.0.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../../../' 4 | 5 | gem 'activerecord', '~> 7.0.0' 6 | 7 | # Rails imposed database gem constraints 8 | gem 'mysql2', '~> 0.5' # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 9 | gem 'pg', '~> 1.1' # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L3 10 | gem 'sqlite3', '~> 1.4' # https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L14 11 | -------------------------------------------------------------------------------- /spec/support/gemfiles/Gemfile.rails-7.1.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '../../../' 4 | 5 | gem 'activerecord', '~> 7.1.0' 6 | 7 | # Rails imposed database gem constraints 8 | gem 'mysql2', '~> 0.5' # https://github.com/rails/rails/blob/7-1-stable/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 9 | gem 'pg', '~> 1.1' # https://github.com/rails/rails/blob/7-1-stable/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L3 10 | gem 'sqlite3', '~> 1.4' # https://github.com/rails/rails/blob/7-1-stable/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L14 11 | -------------------------------------------------------------------------------- /lib/double_entry/errors.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | class DoubleEntryError < RuntimeError; end 4 | class UnknownAccount < DoubleEntryError; end 5 | class AccountIdentifierTooLongError < DoubleEntryError; end 6 | class ScopeIdentifierTooLongError < DoubleEntryError; end 7 | class TransferNotAllowed < DoubleEntryError; end 8 | class TransferIsNegative < DoubleEntryError; end 9 | class TransferCodeTooLongError < DoubleEntryError; end 10 | class DuplicateAccount < DoubleEntryError; end 11 | class DuplicateTransfer < DoubleEntryError; end 12 | class AccountWouldBeSentNegative < DoubleEntryError; end 13 | class AccountWouldBeSentPositiveError < DoubleEntryError; end 14 | class MismatchedCurrencies < DoubleEntryError; end 15 | class MissingAccountError < DoubleEntryError; end 16 | end 17 | -------------------------------------------------------------------------------- /lib/active_record/locking_extensions/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'active_support/log_subscriber' 3 | 4 | module ActiveRecord 5 | module LockingExtensions 6 | class LogSubscriber < ActiveSupport::LogSubscriber 7 | def deadlock_restart(event) 8 | info 'Deadlock causing restart' 9 | debug event[:exception] 10 | end 11 | 12 | def deadlock_retry(event) 13 | info 'Deadlock causing retry' 14 | debug event[:exception] 15 | end 16 | 17 | def duplicate_ignore(event) 18 | info 'Duplicate ignored' 19 | debug event[:exception] 20 | end 21 | 22 | def logger 23 | ActiveRecord::Base.logger 24 | end 25 | end 26 | end 27 | end 28 | 29 | ActiveRecord::LockingExtensions::LogSubscriber.attach_to :double_entry 30 | -------------------------------------------------------------------------------- /spec/support/performance_helper.rb: -------------------------------------------------------------------------------- 1 | module PerformanceHelper 2 | require 'ruby-prof' 3 | 4 | def start_profiling(measure_mode = RubyProf::PROCESS_TIME) 5 | RubyProf.measure_mode = measure_mode 6 | RubyProf.start 7 | end 8 | 9 | def stop_profiling(profile_name = nil) 10 | result = RubyProf.stop 11 | puts "#{profile_name} Time: #{format('%#.3g', total_time(result))}s" 12 | unless ENV.fetch('CI', false) 13 | if profile_name 14 | outdir = './profiles' 15 | Dir.mkdir(outdir) unless Dir.exist?(outdir) 16 | printer = RubyProf::MultiPrinter.new(result) 17 | printer.print(path: outdir, profile: profile_name) 18 | end 19 | end 20 | result 21 | end 22 | 23 | def total_time(result) 24 | result.threads.inject(0) { |time, thread| time + thread.total_time } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/double_entry/configuration.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | include Configurable 4 | 5 | class Configuration 6 | attr_accessor :json_metadata 7 | 8 | def initialize 9 | @json_metadata = false 10 | end 11 | 12 | delegate( 13 | :accounts, 14 | :accounts=, 15 | :scope_identifier_max_length, 16 | :scope_identifier_max_length=, 17 | :account_identifier_max_length, 18 | :account_identifier_max_length=, 19 | to: 'DoubleEntry::Account', 20 | ) 21 | 22 | delegate( 23 | :transfers, 24 | :transfers=, 25 | :code_max_length, 26 | :code_max_length=, 27 | to: 'DoubleEntry::Transfer', 28 | ) 29 | 30 | def define_accounts 31 | yield accounts 32 | end 33 | 34 | def define_transfers 35 | yield transfers 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/generators/double_entry/install/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | require 'double_entry' 2 | 3 | DoubleEntry.configure do |config| 4 | # Use json(b) column in double_entry_lines table to store metadata instead of separate metadata table 5 | config.json_metadata = <%= json_metadata %> 6 | 7 | # config.define_accounts do |accounts| 8 | # user_scope = ->(user) do 9 | # raise 'not a User' unless user.class.name == 'User' 10 | # user.id 11 | # end 12 | # accounts.define(identifier: :savings, scope_identifier: user_scope, positive_only: true) 13 | # accounts.define(identifier: :checking, scope_identifier: user_scope) 14 | # end 15 | # 16 | # config.define_transfers do |transfers| 17 | # transfers.define(from: :checking, to: :savings, code: :deposit) 18 | # transfers.define(from: :savings, to: :checking, code: :withdraw) 19 | # end 20 | end 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | double_entry: 4 | build: . 5 | environment: 6 | POSTGRES_HOST: postgres 7 | MYSQL_HOST: mysql 8 | volumes: 9 | - ./lib:/double_entry/lib 10 | - ./spec/double_entry:/double_entry/spec/double_entry 11 | - ./spec/double_entry_spec.rb:/double_entry/spec/double_entry_spec.rb 12 | - ./spec/generators:/double_entry/spec/generators 13 | - ./spec/active_record:/double_entry/spec/active_record 14 | - ./spec/performance:/double_entry/spec/performance 15 | - ./script:/double_entry/script 16 | depends_on: 17 | - mysql 18 | - postgres 19 | mysql: 20 | image: mysql 21 | environment: 22 | MYSQL_ALLOW_EMPTY_PASSWORD: 'true' 23 | MYSQL_DATABASE: double_entry_test 24 | postgres: 25 | image: postgres:alpine 26 | environment: 27 | POSTGRES_HOST_AUTH_METHOD: trust 28 | POSTGRES_DB: double_entry_test 29 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'database_cleaner' 3 | require 'erb' 4 | require 'yaml' 5 | 6 | FileUtils.mkdir_p 'tmp' 7 | 8 | db_engine = ENV['DB'] || 'mysql' 9 | database_config_file = File.join(__dir__, 'database.yml') 10 | 11 | raise <<-MSG.strip_heredoc unless File.exist?(database_config_file) 12 | Please configure your spec/support/database.yml file. 13 | See spec/support/database.example.yml' 14 | MSG 15 | 16 | ActiveRecord::Base.belongs_to_required_by_default = true if ActiveRecord.version.version >= '5' 17 | database_config_raw = File.read(database_config_file) 18 | database_config_yaml = ERB.new(database_config_raw).result 19 | database_config = YAML.load(database_config_yaml) 20 | ActiveRecord::Base.establish_connection(database_config[db_engine]) 21 | 22 | RSpec.configure do |config| 23 | config.before(:suite) do 24 | DatabaseCleaner.strategy = :truncation 25 | end 26 | 27 | config.before(:example) do 28 | DatabaseCleaner.clean 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2014 Envato Pty Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /spec/double_entry/account_balance_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::AccountBalance do 3 | describe '.table_name' do 4 | subject { DoubleEntry::AccountBalance.table_name } 5 | it { should eq('double_entry_account_balances') } 6 | end 7 | 8 | describe '.scopes_with_minimum_balance_for_account' do 9 | subject(:scopes) { DoubleEntry::AccountBalance.scopes_with_minimum_balance_for_account(minimum_balance, :checking) } 10 | 11 | context "a 'checking' account with balance $100" do 12 | let!(:user) { create(:user, checking_balance: Money.new(100_00)) } 13 | 14 | context 'when searching for balance $99' do 15 | let(:minimum_balance) { Money.new(99_00) } 16 | it { should include user.id.to_s } 17 | end 18 | 19 | context 'when searching for balance $100' do 20 | let(:minimum_balance) { Money.new(100_00) } 21 | it { should include user.id.to_s } 22 | end 23 | 24 | context 'when searching for balance $101' do 25 | let(:minimum_balance) { Money.new(101_00) } 26 | it { should_not include user.id.to_s } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/double_entry/validation/account_fixer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DoubleEntry 4 | module Validation 5 | class AccountFixer 6 | def recalculate_account(account) 7 | DoubleEntry.lock_accounts(account) do 8 | recalculated_balance = Money.zero(account.currency) 9 | 10 | lines_for_account(account).each do |line| 11 | recalculated_balance += line.amount 12 | if line.balance != recalculated_balance 13 | line.update_attribute(:balance, recalculated_balance) 14 | end 15 | end 16 | 17 | update_balance_for_account(account, recalculated_balance) 18 | end 19 | end 20 | 21 | private 22 | 23 | def lines_for_account(account) 24 | Line.where( 25 | account: account.identifier.to_s, 26 | scope: account.scope_identity&.to_s 27 | ).order(:id) 28 | end 29 | 30 | def update_balance_for_account(account, balance) 31 | account_balance = Locking.balance_for_locked_account(account) 32 | account_balance.update_attribute(:balance, balance) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/double_entry/configurable.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | # Make configuring a module or a class simple. 4 | # 5 | # class MyClass 6 | # include Configurable 7 | # 8 | # class Configuration 9 | # attr_accessor :my_config_option 10 | # 11 | # def initialize #:nodoc: 12 | # @my_config_option = "default value" 13 | # end 14 | # end 15 | # end 16 | # 17 | # Then in an initializer (or environments/*.rb) do: 18 | # 19 | # MyClass.configure do |config| 20 | # config.my_config_option = "custom value" 21 | # end 22 | # 23 | # And inside methods in your class you can access your config: 24 | # 25 | # class MyClass 26 | # def my_method 27 | # puts configuration.my_config_option 28 | # end 29 | # end 30 | # 31 | # This is all based on this article: 32 | # 33 | # http://robots.thoughtbot.com/post/344833329/mygem-configure-block 34 | # 35 | module Configurable 36 | def self.included(base) #:nodoc: 37 | base.extend(ClassMethods) 38 | end 39 | 40 | module ClassMethods #:nodoc: 41 | def configuration 42 | @configuration ||= self::Configuration.new 43 | end 44 | alias config configuration 45 | 46 | def configure 47 | yield(configuration) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/generators/double_entry/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | require 'rails/generators/active_record' 4 | 5 | module DoubleEntry 6 | module Generators 7 | class InstallGenerator < Rails::Generators::Base 8 | include Rails::Generators::Migration 9 | 10 | class_option :json_metadata, type: :boolean, default: true 11 | 12 | source_root File.expand_path('../templates', __FILE__) 13 | 14 | def self.next_migration_number(path) 15 | ActiveRecord::Generators::Base.next_migration_number(path) 16 | end 17 | 18 | def copy_migrations 19 | migration_template 'migration.rb', 'db/migrate/create_double_entry_tables.rb', migration_version: migration_version 20 | end 21 | 22 | def create_initializer 23 | template 'initializer.rb', 'config/initializers/double_entry.rb' 24 | end 25 | 26 | def json_metadata 27 | # MySQL JSON support added to AR 5.0 28 | if ActiveRecord.version.version < '5' 29 | false 30 | else 31 | options[:json_metadata] 32 | end 33 | end 34 | 35 | def migration_version 36 | if ActiveRecord.version.version > '5' 37 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | require 'factory_bot' 2 | 3 | User = Class.new(ActiveRecord::Base) 4 | 5 | FactoryBot.define do 6 | factory :user do 7 | username { "user#{__id__}" } 8 | 9 | transient do 10 | savings_balance { false } 11 | checking_balance { false } 12 | bitcoin_balance { false } 13 | end 14 | 15 | after(:create) do |user, evaluator| 16 | if evaluator.savings_balance 17 | DoubleEntry.transfer( 18 | evaluator.savings_balance, 19 | from: DoubleEntry.account(:test, scope: user), 20 | to: DoubleEntry.account(:savings, scope: user), 21 | code: :bonus, 22 | ) 23 | end 24 | if evaluator.checking_balance 25 | DoubleEntry.transfer( 26 | evaluator.checking_balance, 27 | from: DoubleEntry.account(:test, scope: user), 28 | to: DoubleEntry.account(:checking, scope: user), 29 | code: :pay, 30 | ) 31 | end 32 | if evaluator.bitcoin_balance 33 | DoubleEntry.transfer( 34 | evaluator.bitcoin_balance, 35 | from: DoubleEntry.account(:btc_test, scope: user), 36 | to: DoubleEntry.account(:btc_savings, scope: user), 37 | code: :btc_test_transfer, 38 | ) 39 | end 40 | end 41 | end 42 | end 43 | 44 | RSpec.configure do |config| 45 | config.include FactoryBot::Syntax::Methods 46 | end 47 | -------------------------------------------------------------------------------- /spec/support/accounts.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require_relative 'factories' 3 | DoubleEntry.configure do |config| 4 | # A set of accounts to test with 5 | config.define_accounts do |accounts| 6 | user_scope = lambda{|user| user.id} 7 | accounts.define(identifier: :savings, scope_identifier: user_scope, positive_only: true) 8 | accounts.define(identifier: :checking, scope_identifier: user_scope, positive_only: true) 9 | accounts.define(identifier: :test, scope_identifier: user_scope) 10 | accounts.define(identifier: :btc_test, scope_identifier: user_scope, currency: 'BTC') 11 | accounts.define(identifier: :btc_savings, scope_identifier: user_scope, currency: 'BTC') 12 | accounts.define(identifier: :deposit_fees, scope_identifier: user_scope, positive_only: true) 13 | accounts.define(identifier: :account_fees, scope_identifier: user_scope, positive_only: true) 14 | end 15 | 16 | # A set of allowed transfers between accounts 17 | config.define_transfers do |transfers| 18 | transfers.define(from: :test, to: :savings, code: :bonus) 19 | transfers.define(from: :test, to: :checking, code: :pay) 20 | transfers.define(from: :savings, to: :test, code: :test_withdrawal) 21 | transfers.define(from: :btc_test, to: :btc_savings, code: :btc_test_transfer) 22 | transfers.define(from: :savings, to: :deposit_fees, code: :fee) 23 | transfers.define(from: :savings, to: :account_fees, code: :fee) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/double_entry_spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntrySpecHelper 3 | def lines_for_account(account) 4 | lines = DoubleEntry::Line.order(:id) 5 | lines = lines.where(scope: account.scope_identity) if account.scoped? 6 | lines = lines.where(account: account.identifier.to_s) 7 | lines 8 | end 9 | 10 | def perform_deposit(user, amount) 11 | DoubleEntry.transfer( 12 | Money.new(amount), 13 | from: DoubleEntry.account(:test, scope: user), 14 | to: DoubleEntry.account(:savings, scope: user), 15 | code: :bonus, 16 | ) 17 | end 18 | 19 | def transfer_deposit_fee(user, amount) 20 | DoubleEntry.transfer( 21 | Money.new(amount), 22 | from: DoubleEntry.account(:savings, scope: user), 23 | to: DoubleEntry.account(:deposit_fees, scope: user), 24 | code: :fee, 25 | ) 26 | end 27 | 28 | def transfer_account_fee(user, amount) 29 | DoubleEntry.transfer( 30 | Money.new(amount), 31 | from: DoubleEntry.account(:savings, scope: user), 32 | to: DoubleEntry.account(:account_fees, scope: user), 33 | code: :fee, 34 | ) 35 | end 36 | 37 | def perform_btc_deposit(user, amount) 38 | DoubleEntry.transfer( 39 | Money.new(amount, :btc), 40 | from: DoubleEntry.account(:btc_test, scope: user), 41 | to: DoubleEntry.account(:btc_savings, scope: user), 42 | code: :btc_test_transfer, 43 | ) 44 | end 45 | end 46 | 47 | RSpec.configure do |config| 48 | config.include DoubleEntrySpecHelper 49 | end 50 | -------------------------------------------------------------------------------- /.github/workflows/ci-workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: 'Test (Ruby: ${{ matrix.ruby }}, Rails: ${{ matrix.rails }}, DB: ${{ matrix.db }})' 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | ruby: ['3.3', '3.2', '3.1', '3.0'] 10 | rails: ['7.1', '7.0', '6.1'] 11 | db: [mysql, postgres, sqlite] 12 | env: 13 | BUNDLE_GEMFILE: ${{ github.workspace }}/spec/support/gemfiles/Gemfile.rails-${{ matrix.rails }}.x 14 | DB: ${{ matrix.db }} 15 | services: 16 | mysql: 17 | image: mysql:5.7 18 | env: 19 | MYSQL_DATABASE: double_entry_test 20 | MYSQL_USER: mysql 21 | MYSQL_PASSWORD: password 22 | MYSQL_ROOT_PASSWORD: root 23 | ports: 24 | - 3306:3306 25 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 26 | postgres: 27 | image: postgres 28 | env: 29 | POSTGRES_USER: postgres 30 | POSTGRES_PASSWORD: password 31 | POSTGRES_DB: double_entry_test 32 | # Set health checks to wait until postgres has started 33 | options: >- 34 | --health-cmd pg_isready 35 | --health-interval 10s 36 | --health-timeout 5s 37 | --health-retries 5 38 | ports: 39 | - 5432:5432 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | - name: Set up Ruby 44 | uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{ matrix.ruby }} 47 | bundler-cache: true 48 | - run: cp spec/support/database.ci.yml spec/support/database.yml 49 | - run: bundle exec rspec 50 | - run: ruby script/jack_hammer -t 2000 51 | -------------------------------------------------------------------------------- /spec/double_entry/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Configuration do 3 | its(:accounts) { should be_a DoubleEntry::Account::Set } 4 | its(:transfers) { should be_a DoubleEntry::Transfer::Set } 5 | 6 | describe 'max lengths' do 7 | context 'given a max length has not been set' do 8 | its(:code_max_length) { should be_nil } 9 | its(:scope_identifier_max_length) { should be_nil } 10 | its(:account_identifier_max_length) { should be_nil } 11 | end 12 | 13 | context 'given a code max length of 10 has been set' do 14 | before { subject.code_max_length = 10 } 15 | its(:code_max_length) { should be 10 } 16 | end 17 | 18 | context 'given a scope identifier max length of 11 has been set' do 19 | before { subject.scope_identifier_max_length = 11 } 20 | its(:scope_identifier_max_length) { should be 11 } 21 | end 22 | 23 | context 'given an account identifier max length of 9 has been set' do 24 | before { subject.account_identifier_max_length = 9 } 25 | its(:account_identifier_max_length) { should be 9 } 26 | end 27 | 28 | after do 29 | subject.code_max_length = nil 30 | subject.scope_identifier_max_length = nil 31 | subject.account_identifier_max_length = nil 32 | end 33 | end 34 | 35 | describe '#define_accounts' do 36 | it 'yields the accounts set' do 37 | expect do |block| 38 | subject.define_accounts(&block) 39 | end.to yield_with_args(be_a DoubleEntry::Account::Set) 40 | end 41 | end 42 | 43 | describe '#define_transfers' do 44 | it 'yields the transfers set' do 45 | expect do |block| 46 | subject.define_transfers(&block) 47 | end.to yield_with_args(be_a DoubleEntry::Transfer::Set) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/performance/double_entry_performance_spec.rb: -------------------------------------------------------------------------------- 1 | module DoubleEntry 2 | RSpec.describe DoubleEntry do 3 | describe 'transfer performance' do 4 | include PerformanceHelper 5 | let(:user) { create(:user) } 6 | let(:amount) { Money.new(10_00) } 7 | let(:test) { DoubleEntry.account(:test, scope: user) } 8 | let(:savings) { DoubleEntry.account(:savings, scope: user) } 9 | 10 | it 'creates a lot of transfers quickly without metadata' do 11 | profile_transfers_with_metadata(nil) 12 | # local results: 6.44, 5.93, 5.94 13 | end 14 | 15 | it 'creates a lot of transfers quickly with metadata & separate metadata table' do 16 | big_metadata = {} 17 | 8.times { |i| big_metadata["key#{i}".to_sym] = "value#{i}" } 18 | profile_transfers_with_metadata(big_metadata, 'transfer-with-metadata-table') 19 | # local results: 21.2, 21.6, 20.9 20 | end 21 | 22 | it 'creates a lot of transfers quickly with metadata & metadata column on lines table', skip: ActiveRecord.version.version < '5' do 23 | DoubleEntry.config.json_metadata = true 24 | big_metadata = {} 25 | 8.times { |i| big_metadata["key#{i}".to_sym] = "value#{i}" } 26 | profile_transfers_with_metadata(big_metadata, 'transfer-with-metadata-column') 27 | DoubleEntry.config.json_metadata = false 28 | # local results: 21.2, 21.6, 20.9 29 | end 30 | end 31 | 32 | def profile_transfers_with_metadata(metadata, profile_name = nil) 33 | start_profiling 34 | options = { from: test, to: savings, code: :bonus } 35 | options[:metadata] = metadata if metadata 36 | 100.times { Transfer.transfer(amount, options) } 37 | profile_name ||= 'transfer' 38 | stop_profiling(profile_name) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /double_entry.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | require 'double_entry/version' 6 | 7 | Gem::Specification.new do |gem| 8 | gem.name = 'double_entry' 9 | gem.version = DoubleEntry::VERSION 10 | gem.authors = ['Envato'] 11 | gem.email = ['rubygems@envato.com'] 12 | gem.summary = 'Tools to build your double entry financial ledger' 13 | gem.homepage = 'https://github.com/envato/double_entry' 14 | gem.license = 'MIT' 15 | 16 | gem.metadata = { 17 | 'bug_tracker_uri' => 'https://github.com/envato/double_entry/issues', 18 | 'changelog_uri' => "https://github.com/envato/double_entry/blob/v#{gem.version}/CHANGELOG.md", 19 | 'documentation_uri' => "https://www.rubydoc.info/gems/double_entry/#{gem.version}", 20 | 'source_code_uri' => "https://github.com/envato/double_entry/tree/v#{gem.version}", 21 | } 22 | 23 | gem.files = `git ls-files -z`.split("\x0").select do |f| 24 | f.match(%r{^(?:double_entry.gemspec|README|LICENSE|CHANGELOG|lib/)}) 25 | end 26 | gem.require_paths = ['lib'] 27 | gem.required_ruby_version = '>= 3' 28 | 29 | gem.add_dependency 'activerecord', '>= 6.1.0' 30 | gem.add_dependency 'activesupport', '>= 6.1.0' 31 | gem.add_dependency 'money', '>= 6.0.0' 32 | gem.add_dependency 'railties', '>= 6.1.0' 33 | 34 | gem.add_development_dependency 'mysql2' 35 | gem.add_development_dependency 'pg' 36 | gem.add_development_dependency 'rake' 37 | gem.add_development_dependency 'sqlite3' 38 | 39 | gem.add_development_dependency 'database_cleaner' 40 | gem.add_development_dependency 'factory_bot' 41 | gem.add_development_dependency 'generator_spec' 42 | gem.add_development_dependency 'rspec' 43 | gem.add_development_dependency 'rspec-its' 44 | gem.add_development_dependency 'ruby-prof' 45 | gem.add_development_dependency 'timecop' 46 | end 47 | -------------------------------------------------------------------------------- /lib/double_entry/account_balance.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | # Account balance records cache the current balance for each account. They 4 | # also provide a database representation of an account that we can use to do 5 | # DB level locking. 6 | # 7 | # See DoubleEntry::Locking for more info on locking. 8 | # 9 | # Account balances are created on demand when transfers occur. 10 | class AccountBalance < ActiveRecord::Base 11 | delegate :currency, to: :account 12 | 13 | def balance 14 | self[:balance] && Money.new(self[:balance], currency) 15 | end 16 | 17 | def balance=(money) 18 | self[:balance] = (money && money.fractional) 19 | end 20 | 21 | def account=(account) 22 | self[:account] = account.identifier.to_s 23 | self[:scope] = account.scope_identity 24 | account 25 | end 26 | 27 | def account 28 | DoubleEntry.account(self[:account].to_sym, scope_identity: self[:scope]) 29 | end 30 | 31 | def self.find_by_account(account, options = {}) 32 | scope = where(scope: account.scope_identity, account: account.identifier.to_s) 33 | scope = scope.lock(true) if options[:lock] 34 | scope.first 35 | end 36 | 37 | # Identify the scopes with the given account identifier holding at least 38 | # the provided minimum balance. 39 | # 40 | # @example Find users with at least $1,000,000 in their savings accounts 41 | # DoubleEntry::AccountBalance.scopes_with_minimum_balance_for_account( 42 | # 1_000_000.dollars, 43 | # :savings, 44 | # ) # might return the user ids: [ '1423', '12232', '34729' ] 45 | # @param [Money] minimum_balance Minimum account balance a scope must have 46 | # to be included in the result set. 47 | # @param [Symbol] account_identifier 48 | # @return [Array] Scopes 49 | # 50 | def self.scopes_with_minimum_balance_for_account(minimum_balance, account_identifier) 51 | where(account: account_identifier).where('balance >= ?', minimum_balance.fractional).pluck(:scope) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table "double_entry_account_balances", force: true do |t| 5 | t.string "account", null: false 6 | t.string "scope" 7 | t.bigint "balance", null: false 8 | t.timestamps null: false 9 | end 10 | 11 | add_index "double_entry_account_balances", ["account"], name: "index_account_balances_on_account" 12 | add_index "double_entry_account_balances", ["scope", "account"], name: "index_account_balances_on_scope_and_account", unique: true 13 | 14 | create_table "double_entry_lines", force: true do |t| 15 | t.string "account", null: false 16 | t.string "scope" 17 | t.string "code", null: false 18 | t.bigint "amount", null: false 19 | t.bigint "balance", null: false 20 | t.references "partner", index: false 21 | t.string "partner_account", null: false 22 | t.string "partner_scope" 23 | t.references "detail", index: false, polymorphic: true 24 | unless ActiveRecord.version.version < '5' 25 | if ActiveRecord::Base.connection.adapter_name == "PostgreSQL" 26 | t.jsonb "metadata" 27 | else 28 | t.json "metadata" 29 | end 30 | end 31 | t.timestamps null: false 32 | end 33 | 34 | add_index "double_entry_lines", ["account", "code", "created_at", "partner_account"], name: "lines_account_code_created_at_partner_account_idx" 35 | add_index "double_entry_lines", ["account", "created_at"], name: "lines_account_created_at_idx" 36 | add_index "double_entry_lines", ["scope", "account", "created_at"], name: "lines_scope_account_created_at_idx" 37 | add_index "double_entry_lines", ["scope", "account", "id"], name: "lines_scope_account_id_idx" 38 | 39 | create_table "double_entry_line_checks", force: true do |t| 40 | t.references "last_line", null: false, index: false 41 | t.boolean "errors_found", null: false 42 | t.text "log" 43 | t.timestamps null: false 44 | end 45 | 46 | add_index "double_entry_line_checks", ["created_at", "last_line_id"], name: "line_checks_created_at_last_line_id_idx" 47 | 48 | create_table "double_entry_line_metadata", force: true do |t| 49 | t.references "line", null: false, index: false 50 | t.string "key", null: false 51 | t.string "value", null: false 52 | t.timestamps null: false 53 | end 54 | 55 | add_index "double_entry_line_metadata", ["line_id", "key", "value"], name: "lines_meta_line_id_key_value_idx" 56 | 57 | # test table only 58 | create_table "users", force: true do |t| 59 | t.string "username", null: false 60 | t.timestamps null: false 61 | end 62 | 63 | add_index "users", ["username"], name: "index_users_on_username", unique: true 64 | end 65 | -------------------------------------------------------------------------------- /lib/generators/double_entry/install/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class CreateDoubleEntryTables < ActiveRecord::Migration<%= migration_version %> 2 | def self.up 3 | create_table "double_entry_account_balances" do |t| 4 | t.string "account", null: false 5 | t.string "scope" 6 | t.bigint "balance", null: false 7 | t.timestamps null: false 8 | end 9 | 10 | add_index "double_entry_account_balances", ["account"], name: "index_account_balances_on_account" 11 | add_index "double_entry_account_balances", ["scope", "account"], name: "index_account_balances_on_scope_and_account", unique: true 12 | 13 | create_table "double_entry_lines" do |t| 14 | t.string "account", null: false 15 | t.string "scope" 16 | t.string "code", null: false 17 | t.bigint "amount", null: false 18 | t.bigint "balance", null: false 19 | t.references "partner", index: false 20 | t.string "partner_account", null: false 21 | t.string "partner_scope" 22 | t.references "detail", index: false, polymorphic: true 23 | <%- if json_metadata -%> 24 | if ActiveRecord::Base.connection.adapter_name == "PostgreSQL" 25 | t.jsonb "metadata" 26 | else 27 | t.json "metadata" 28 | end 29 | <%- end -%> 30 | t.timestamps null: false 31 | end 32 | 33 | add_index "double_entry_lines", ["account", "code", "created_at"], name: "lines_account_code_created_at_idx" 34 | add_index "double_entry_lines", ["account", "created_at"], name: "lines_account_created_at_idx" 35 | add_index "double_entry_lines", ["scope", "account", "created_at"], name: "lines_scope_account_created_at_idx" 36 | add_index "double_entry_lines", ["scope", "account", "id"], name: "lines_scope_account_id_idx" 37 | 38 | create_table "double_entry_line_checks" do |t| 39 | t.references "last_line", null: false, index: false 40 | t.boolean "errors_found", null: false 41 | t.text "log" 42 | t.timestamps null: false 43 | end 44 | 45 | add_index "double_entry_line_checks", ["created_at", "last_line_id"], name: "line_checks_created_at_last_line_id_idx" 46 | <%- unless json_metadata -%> 47 | 48 | create_table "double_entry_line_metadata" do |t| 49 | t.references "line", null: false, index: false 50 | t.string "key", null: false 51 | t.string "value", null: false 52 | t.timestamps null: false 53 | end 54 | 55 | add_index "double_entry_line_metadata", ["line_id", "key", "value"], name: "lines_meta_line_id_key_value_idx" 56 | <%- end -%> 57 | end 58 | 59 | def self.down 60 | drop_table "double_entry_line_metadata" if table_exists?("double_entry_line_metadata") 61 | drop_table "double_entry_line_checks" 62 | drop_table "double_entry_lines" 63 | drop_table "double_entry_account_balances" 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/active_record/locking_extensions.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'active_support/notifications' 3 | 4 | module ActiveRecord 5 | # These methods are available as class methods on ActiveRecord::Base. 6 | module LockingExtensions 7 | # Execute the given block within a database transaction, and retry the 8 | # transaction from the beginning if a RestartTransaction exception is raised. 9 | def restartable_transaction(&block) 10 | transaction(&block) 11 | rescue ActiveRecord::RestartTransaction 12 | retry 13 | end 14 | 15 | # Execute the given block, and retry the current restartable transaction if a 16 | # MySQL deadlock occurs. 17 | def with_restart_on_deadlock 18 | yield 19 | rescue ActiveRecord::StatementInvalid => exception 20 | if exception.message =~ /deadlock/i || exception.message =~ /database is locked/i 21 | ActiveSupport::Notifications.publish('deadlock_restart.double_entry', exception: exception) 22 | 23 | raise ActiveRecord::RestartTransaction 24 | else 25 | raise 26 | end 27 | end 28 | 29 | # Create the record, but ignore the exception if there's a duplicate. 30 | # if there is a deadlock, retry 31 | def create_ignoring_duplicates!(*args) 32 | retry_deadlocks do 33 | ignoring_duplicates do 34 | create!(*args) 35 | end 36 | end 37 | end 38 | 39 | private 40 | 41 | def ignoring_duplicates 42 | # Error examples: 43 | # PG::Error: ERROR: duplicate key value violates unique constraint 44 | # Mysql2::Error: Duplicate entry 'keith' for key 'index_users_on_username': INSERT INTO `users... 45 | # ActiveRecord::RecordNotUnique SQLite3::ConstraintException: column username is not unique: INSERT INTO "users"... 46 | yield 47 | rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotUnique => exception 48 | if exception.message =~ /duplicate/i || exception.message =~ /ConstraintException/ 49 | ActiveSupport::Notifications.publish('duplicate_ignore.double_entry', exception: exception) 50 | 51 | # Just ignore it...someone else has already created the record. 52 | else 53 | raise 54 | end 55 | end 56 | 57 | def retry_deadlocks 58 | # Error examples: 59 | # PG::Error: ERROR: deadlock detected 60 | # Mysql::Error: Deadlock found when trying to get lock 61 | yield 62 | rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotUnique => exception 63 | if exception.message =~ /deadlock/i || exception.message =~ /database is locked/i 64 | # Somebody else is in the midst of creating the record. We'd better 65 | # retry, so we ensure they're done before we move on. 66 | ActiveSupport::Notifications.publish('deadlock_retry.double_entry', exception: exception) 67 | 68 | retry 69 | else 70 | raise 71 | end 72 | end 73 | end 74 | 75 | # Raise this inside a restartable_transaction to retry the transaction from the beginning. 76 | class RestartTransaction < RuntimeError 77 | end 78 | end 79 | 80 | ActiveRecord::Base.extend(ActiveRecord::LockingExtensions) 81 | -------------------------------------------------------------------------------- /lib/double_entry/balance_calculator.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module BalanceCalculator 4 | extend self 5 | 6 | # Get the current or historic balance of an account. 7 | # 8 | # @param account [DoubleEntry::Account:Instance] 9 | # @option args :from [Time] 10 | # @option args :to [Time] 11 | # @option args :at [Time] 12 | # @option args :code [Symbol] 13 | # @option args :codes [Array] 14 | # @return [Money] 15 | # 16 | def calculate(account, args = {}) 17 | options = Options.new(account, args) 18 | relations = RelationBuilder.new(options) 19 | lines = relations.build 20 | 21 | if options.between? || options.code? 22 | # from and to or code lookups have to be done via sum 23 | Money.new(lines.sum(:amount), account.currency) 24 | else 25 | # all other lookups can be performed with running balances 26 | result = lines. 27 | from(lines_table_name(options)). 28 | order('id DESC'). 29 | limit(1). 30 | pluck(:balance) 31 | result.empty? ? Money.zero(account.currency) : Money.new(result.first, account.currency) 32 | end 33 | end 34 | 35 | private 36 | 37 | def lines_table_name(options) 38 | "#{Line.quoted_table_name}#{' USE INDEX (lines_scope_account_id_idx)' if force_index?(options)}" 39 | end 40 | 41 | def force_index?(options) 42 | # This is to work around a MySQL 5.1 query optimiser bug that causes the ORDER BY 43 | # on the query to fail in some circumstances, resulting in an old balance being 44 | # returned. This was biting us intermittently in spec runs. 45 | # See http://bugs.mysql.com/bug.php?id=51431 46 | options.scope? && Line.connection.adapter_name.match(/mysql/i) 47 | end 48 | 49 | # @api private 50 | class Options 51 | attr_reader :account, :scope, :from, :to, :at, :codes 52 | 53 | def initialize(account, args = {}) 54 | @account = account.identifier.to_s 55 | @scope = account.scope_identity 56 | @codes = (args[:codes].to_a << args[:code]).compact 57 | @from = args[:from] 58 | @to = args[:to] 59 | @at = args[:at] 60 | end 61 | 62 | def at? 63 | !!at 64 | end 65 | 66 | def between? 67 | !!(from && to && !at?) 68 | end 69 | 70 | def code? 71 | codes.present? 72 | end 73 | 74 | def scope? 75 | !!scope 76 | end 77 | end 78 | 79 | # @api private 80 | class RelationBuilder 81 | attr_reader :options 82 | delegate :account, :scope, :scope?, :from, :to, :between?, :at, :at?, :codes, :code?, to: :options 83 | 84 | def initialize(options) 85 | @options = options 86 | end 87 | 88 | def build 89 | lines = Line.where(account: account) 90 | lines = lines.where('created_at <= ?', at) if at? 91 | lines = lines.where(created_at: from..to) if between? 92 | lines = lines.where(code: codes) if code? 93 | lines = lines.where(scope: scope) if scope? 94 | lines 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/double_entry/balance_calculator_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | RSpec.describe DoubleEntry::BalanceCalculator do 4 | describe '#calculate' do 5 | let(:account) { DoubleEntry.account(:test, scope: scope) } 6 | let(:scope) { create(:user) } 7 | let(:from) { nil } 8 | let(:to) { nil } 9 | let(:at) { nil } 10 | let(:code) { nil } 11 | let(:codes) { nil } 12 | let(:relation) { double.as_null_object } 13 | 14 | before do 15 | allow(DoubleEntry::Line).to receive(:where).and_return(relation) 16 | DoubleEntry::BalanceCalculator.calculate( 17 | account, 18 | scope: scope, 19 | from: from, 20 | to: to, 21 | at: at, 22 | code: code, 23 | codes: codes, 24 | ) 25 | end 26 | 27 | describe 'what happens with different times' do 28 | context 'when we want to sum the lines before a given created_at date' do 29 | let(:at) { Time.parse('2014-06-19 15:09:18 +1000') } 30 | 31 | it 'scopes the lines summed to times before (or at) the given time' do 32 | expect(relation).to have_received(:where).with( 33 | 'created_at <= ?', Time.parse('2014-06-19 15:09:18 +1000') 34 | ) 35 | end 36 | 37 | context 'when a time range is also specified' do 38 | let(:from) { Time.parse('2014-06-19 10:09:18 +1000') } 39 | let(:to) { Time.parse('2014-06-19 20:09:18 +1000') } 40 | 41 | it 'ignores the time range when summing the lines' do 42 | expect(relation).to_not have_received(:where).with( 43 | created_at: Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000'), 44 | ) 45 | expect(relation).to_not have_received(:sum) 46 | end 47 | end 48 | end 49 | 50 | context 'when we want to sum the lines between a given range' do 51 | let(:from) { Time.parse('2014-06-19 10:09:18 +1000') } 52 | let(:to) { Time.parse('2014-06-19 20:09:18 +1000') } 53 | 54 | it 'scopes the lines summed to times within the given range' do 55 | expect(relation).to have_received(:where).with( 56 | created_at: Time.parse('2014-06-19 10:09:18 +1000')..Time.parse('2014-06-19 20:09:18 +1000'), 57 | ) 58 | expect(relation).to have_received(:sum).with(:amount) 59 | end 60 | end 61 | end 62 | 63 | context 'when a single code is provided' do 64 | let(:code) { 'code1' } 65 | 66 | it 'scopes the lines summed by the given code' do 67 | expect(relation).to have_received(:where).with(code: ['code1']) 68 | expect(relation).to have_received(:sum).with(:amount) 69 | end 70 | end 71 | 72 | context 'when a list of codes is provided' do 73 | let(:codes) { %w(code1 code2) } 74 | 75 | it 'scopes the lines summed by the given codes' do 76 | expect(relation).to have_received(:where).with(code: %w(code1 code2)) 77 | expect(relation).to have_received(:sum).with(:amount) 78 | end 79 | end 80 | 81 | context 'when no codes are provided' do 82 | it 'does not scope the lines summed by any code' do 83 | expect(relation).to_not have_received(:where).with(code: anything) 84 | expect(relation).to_not have_received(:sum).with(:amount) 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/double_entry/line_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry::Line do 3 | it 'has a table name prefixed with double_entry_' do 4 | expect(DoubleEntry::Line.table_name).to eq 'double_entry_lines' 5 | end 6 | 7 | describe 'persistance' do 8 | let(:line_to_persist) do 9 | DoubleEntry::Line.new( 10 | amount: Money.new(10_00), 11 | balance: Money.zero, 12 | account: account, 13 | partner_account: partner_account, 14 | code: code, 15 | ) 16 | end 17 | let(:account) { DoubleEntry.account(:test, scope_identity: '17') } 18 | let(:partner_account) { DoubleEntry.account(:test, scope_identity: '72') } 19 | let(:code) { :test_code } 20 | 21 | subject(:persisted_line) do 22 | line_to_persist.save! 23 | line_to_persist.reload 24 | end 25 | 26 | describe 'attributes' do 27 | context 'given code = :the_code' do 28 | let(:code) { :the_code } 29 | its(:code) { should eq :the_code } 30 | end 31 | 32 | context 'given code = nil' do 33 | let(:code) { nil } 34 | let(:expected_error) do 35 | if defined?(ActiveRecord::NotNullViolation) 36 | ActiveRecord::NotNullViolation 37 | else 38 | ActiveRecord::StatementInvalid 39 | end 40 | end 41 | specify { expect { line_to_persist.save! }.to raise_error(expected_error) } 42 | end 43 | 44 | context 'given account = :test, 54 ' do 45 | let(:account) { DoubleEntry.account(:test, scope_identity: '54') } 46 | its('account.account.identifier') { should eq :test } 47 | its('account.scope_identity') { should eq '54' } 48 | end 49 | 50 | context 'given partner_account = :test, 91 ' do 51 | let(:partner_account) { DoubleEntry.account(:test, scope_identity: '91') } 52 | its('partner_account.account.identifier') { should eq :test } 53 | its('partner_account.scope_identity') { should eq '91' } 54 | end 55 | 56 | context 'currency' do 57 | let(:account) { DoubleEntry.account(:btc_test, scope_identity: '17') } 58 | let(:partner_account) { DoubleEntry.account(:btc_test, scope_identity: '72') } 59 | its(:currency) { should eq 'BTC' } 60 | end 61 | 62 | context 'given detail = nil' do 63 | specify { expect { line_to_persist.save! }.not_to raise_error(ActiveRecord::RecordInvalid) } 64 | end 65 | end 66 | 67 | context 'when balance is sent negative' do 68 | before { DoubleEntry::Account.accounts.define(identifier: :a_positive_only_acc, positive_only: true) } 69 | let(:account) { DoubleEntry.account(:a_positive_only_acc) } 70 | let(:line) { DoubleEntry::Line.new(balance: Money.new(-1), account: account) } 71 | 72 | it 'raises AccountWouldBeSentNegative error' do 73 | expect { line.save }.to raise_error DoubleEntry::AccountWouldBeSentNegative 74 | end 75 | end 76 | 77 | context 'when balance is sent positive' do 78 | before { DoubleEntry::Account.accounts.define(identifier: :a_negative_only_acc, negative_only: true) } 79 | let(:account) { DoubleEntry.account(:a_negative_only_acc) } 80 | let(:line) { DoubleEntry::Line.new(balance: Money.new(1), account: account) } 81 | 82 | it 'raises AccountWouldBeSentPositiveError' do 83 | expect { line.save }.to raise_error DoubleEntry::AccountWouldBeSentPositiveError 84 | end 85 | end 86 | 87 | it 'has a table name prefixed with double_entry_' do 88 | expect(DoubleEntry::Line.table_name).to eq 'double_entry_lines' 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/active_record/locking_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | RSpec.describe ActiveRecord::LockingExtensions do 4 | PG_DEADLOCK = ActiveRecord::StatementInvalid.new('PG::Error: ERROR: deadlock detected') 5 | MYSQL_DEADLOCK = ActiveRecord::StatementInvalid.new('Mysql::Error: Deadlock found when trying to get lock') 6 | SQLITE3_LOCK = ActiveRecord::StatementInvalid.new('SQLite3::BusyException: database is locked: UPDATE...') 7 | 8 | context '#restartable_transaction' do 9 | it "keeps running the lock until a ActiveRecord::RestartTransaction isn't raised" do 10 | expect(User).to receive(:create!).ordered.and_raise(ActiveRecord::RestartTransaction) 11 | expect(User).to receive(:create!).ordered.and_raise(ActiveRecord::RestartTransaction) 12 | expect(User).to receive(:create!).ordered.and_return(true) 13 | 14 | expect { User.restartable_transaction { User.create! } }.to_not raise_error 15 | end 16 | end 17 | 18 | context '#with_restart_on_deadlock' do 19 | shared_examples 'abstract adapter' do 20 | it 'raises a ActiveRecord::RestartTransaction error if a deadlock occurs' do 21 | expect { User.with_restart_on_deadlock { fail exception } }. 22 | to raise_error(ActiveRecord::RestartTransaction) 23 | end 24 | 25 | it 'publishes a notification' do 26 | expect(ActiveSupport::Notifications). 27 | to receive(:publish). 28 | with('deadlock_restart.double_entry', hash_including(exception: exception)) 29 | expect { User.with_restart_on_deadlock { fail exception } }.to raise_error(ActiveRecord::RestartTransaction) 30 | end 31 | end 32 | 33 | context 'mysql' do 34 | let(:exception) { MYSQL_DEADLOCK } 35 | 36 | it_behaves_like 'abstract adapter' 37 | end 38 | 39 | context 'postgres' do 40 | let(:exception) { PG_DEADLOCK } 41 | 42 | it_behaves_like 'abstract adapter' 43 | end 44 | 45 | context 'sqlite' do 46 | let(:exception) { SQLITE3_LOCK } 47 | 48 | it_behaves_like 'abstract adapter' 49 | end 50 | end 51 | 52 | context '#create_ignoring_duplicates' do 53 | it 'does not raise an error if a duplicate index error is raised in the database' do 54 | create(:user, username: 'keith') 55 | 56 | expect { create(:user, username: 'keith') }.to raise_error(ActiveRecord::RecordNotUnique) 57 | expect { User.create_ignoring_duplicates! username: 'keith' }.to_not raise_error 58 | end 59 | 60 | it 'publishes a notification when a duplicate is encountered' do 61 | create(:user, username: 'keith') 62 | 63 | expect(ActiveSupport::Notifications). 64 | to receive(:publish). 65 | with('duplicate_ignore.double_entry', hash_including(exception: kind_of(ActiveRecord::RecordNotUnique))) 66 | 67 | expect { User.create_ignoring_duplicates! username: 'keith' }.to_not raise_error 68 | end 69 | 70 | shared_examples 'abstract adapter' do 71 | it 'retries the creation if a deadlock error is raised from the database' do 72 | expect(User).to receive(:create!).ordered.and_raise(exception) 73 | expect(User).to receive(:create!).ordered.and_return(true) 74 | 75 | expect { User.create_ignoring_duplicates! }.to_not raise_error 76 | end 77 | 78 | it 'publishes a notification on each retry' do 79 | expect(User).to receive(:create!).ordered.and_raise(exception) 80 | expect(User).to receive(:create!).ordered.and_raise(exception) 81 | expect(User).to receive(:create!).ordered.and_return(true) 82 | 83 | expect(ActiveSupport::Notifications). 84 | to receive(:publish). 85 | with('deadlock_retry.double_entry', hash_including(exception: exception)). 86 | twice 87 | 88 | expect { User.create_ignoring_duplicates! }.to_not raise_error 89 | end 90 | end 91 | 92 | context 'mysql' do 93 | let(:exception) { MYSQL_DEADLOCK } 94 | 95 | it_behaves_like 'abstract adapter' 96 | end 97 | 98 | context 'postgres' do 99 | let(:exception) { PG_DEADLOCK } 100 | 101 | it_behaves_like 'abstract adapter' 102 | end 103 | 104 | context 'sqlite' do 105 | let(:exception) { SQLITE3_LOCK } 106 | 107 | it_behaves_like 'abstract adapter' 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/double_entry/account_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | RSpec.describe Account do 4 | let(:identity_scope) { ->(value) { value } } 5 | 6 | describe '::new' do 7 | context 'given a account_identifier_max_length of 31' do 8 | before { Account.account_identifier_max_length = 31 } 9 | after { Account.account_identifier_max_length = nil } 10 | 11 | context 'given an identifier 31 characters in length' do 12 | let(:identifier) { 'xxxxxxxx 31 characters xxxxxxxx' } 13 | specify do 14 | expect { Account.new(identifier: identifier) }.to_not raise_error 15 | end 16 | end 17 | 18 | context 'given an identifier 32 characters in length' do 19 | let(:identifier) { 'xxxxxxxx 32 characters xxxxxxxxx' } 20 | specify do 21 | expect { Account.new(identifier: identifier) }.to raise_error AccountIdentifierTooLongError, /'#{identifier}'/ 22 | end 23 | end 24 | end 25 | end 26 | 27 | describe Account::Instance do 28 | it 'is sortable' do 29 | account = Account.new(identifier: 'savings', scope_identifier: identity_scope) 30 | a = Account::Instance.new(account: account, scope: '123') 31 | b = Account::Instance.new(account: account, scope: '456') 32 | expect([b, a].sort).to eq [a, b] 33 | end 34 | 35 | it 'is hashable' do 36 | account = Account.new(identifier: 'savings', scope_identifier: identity_scope) 37 | a1 = Account::Instance.new(account: account, scope: '123') 38 | a2 = Account::Instance.new(account: account, scope: '123') 39 | b = Account::Instance.new(account: account, scope: '456') 40 | 41 | expect(a1.hash).to eq a2.hash 42 | expect(a1.hash).to_not eq b.hash 43 | end 44 | 45 | describe '::new' do 46 | let(:account) { Account.new(identifier: 'x', scope_identifier: identity_scope) } 47 | subject(:initialize_account_instance) { Account::Instance.new(account: account, scope: scope) } 48 | 49 | context 'given a scope_identifier_max_length of 23' do 50 | before { Account.scope_identifier_max_length = 23 } 51 | after { Account.scope_identifier_max_length = nil } 52 | 53 | context 'given a scope identifier 23 characters in length' do 54 | let(:scope) { 'xxxx 23 characters xxxx' } 55 | specify { expect { initialize_account_instance }.to_not raise_error } 56 | end 57 | 58 | context 'given a scope identifier 24 characters in length' do 59 | let(:scope) { 'xxxx 24 characters xxxxx' } 60 | specify { expect { initialize_account_instance }.to raise_error ScopeIdentifierTooLongError, /'#{scope}'/ } 61 | end 62 | end 63 | end 64 | end 65 | 66 | describe 'currency' do 67 | it 'defaults to USD currency' do 68 | account = DoubleEntry::Account.new(identifier: 'savings', scope_identifier: identity_scope) 69 | expect(DoubleEntry::Account::Instance.new(account: account).currency).to eq('USD') 70 | end 71 | 72 | it 'allows the currency to be set' do 73 | account = DoubleEntry::Account.new(identifier: 'savings', scope_identifier: identity_scope, currency: 'AUD') 74 | expect(DoubleEntry::Account::Instance.new(account: account).currency).to eq('AUD') 75 | end 76 | end 77 | 78 | describe Account::Set do 79 | subject(:set) { described_class.new } 80 | 81 | describe '#find' do 82 | before do 83 | set.define(identifier: :savings) 84 | set.define(identifier: :checking, scope_identifier: ar_class) 85 | end 86 | 87 | let(:ar_class) { double(:ar_class) } 88 | 89 | it 'finds unscoped accounts' do 90 | expect(set.find(:savings, false)).to be_an Account 91 | expect(set.find(:savings, false).identifier).to eq :savings 92 | 93 | expect { set.find(:savings, true) }.to raise_error(UnknownAccount) 94 | end 95 | 96 | it 'finds scoped accounts' do 97 | expect(set.find(:checking, true)).to be_an Account 98 | expect(set.find(:checking, true).identifier).to eq :checking 99 | 100 | expect { set.find(:checking, false) }.to raise_error(UnknownAccount) 101 | end 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/double_entry/validation/line_check.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'set' 3 | 4 | module DoubleEntry 5 | module Validation 6 | class LineCheck < ActiveRecord::Base 7 | 8 | def self.last_line_id_checked 9 | order('created_at DESC').limit(1).pluck(:last_line_id).first || 0 10 | end 11 | 12 | def self.perform!(fixer: nil) 13 | new.perform(fixer: fixer) 14 | end 15 | 16 | def perform(fixer: nil) 17 | log = '' 18 | current_line_id = nil 19 | 20 | active_accounts = Set.new 21 | incorrect_accounts = Set.new 22 | 23 | new_lines_since_last_run.find_each do |line| 24 | incorrect_accounts << line.account unless running_balance_correct?(line, log) 25 | active_accounts << line.account 26 | current_line_id = line.id 27 | end 28 | 29 | active_accounts.each do |account| 30 | incorrect_accounts << account unless cached_balance_correct?(account, log) 31 | end 32 | 33 | incorrect_accounts.each(&fixer.method(:recalculate_account)) if fixer 34 | 35 | unless active_accounts.empty? 36 | LineCheck.create!( 37 | errors_found: incorrect_accounts.any?, 38 | last_line_id: current_line_id, 39 | log: log, 40 | ) 41 | end 42 | end 43 | 44 | private 45 | 46 | def new_lines_since_last_run 47 | Line.with_id_greater_than(LineCheck.last_line_id_checked) 48 | end 49 | 50 | def running_balance_correct?(line, log) 51 | previous_line = find_previous_line(line.account.identifier.to_s, line.scope, line.id) 52 | 53 | previous_balance = previous_line.length == 1 ? previous_line[0].balance : Money.zero(line.account.currency) 54 | 55 | if line.balance != (line.amount + previous_balance) 56 | log << line_error_message(line, previous_line, previous_balance) 57 | end 58 | 59 | line.balance == previous_balance + line.amount 60 | end 61 | 62 | def find_previous_line(identifier, scope, id) 63 | # yes, it needs to be find_by_sql, because any other find will be affected 64 | # by the find_each call in perform! 65 | 66 | if scope.nil? 67 | Line.find_by_sql([<<-SQL, identifier, id]) 68 | SELECT * FROM #{Line.quoted_table_name} #{force_index} 69 | WHERE account = ? 70 | AND scope IS NULL 71 | AND id < ? 72 | ORDER BY id DESC 73 | LIMIT 1 74 | SQL 75 | else 76 | Line.find_by_sql([<<-SQL, identifier, scope, id]) 77 | SELECT * FROM #{Line.quoted_table_name} #{force_index} 78 | WHERE account = ? 79 | AND scope = ? 80 | AND id < ? 81 | ORDER BY id DESC 82 | LIMIT 1 83 | SQL 84 | end 85 | end 86 | 87 | def force_index 88 | # Another work around for the MySQL 5.1 query optimiser bug that causes the ORDER BY 89 | # on the query to fail in some circumstances, resulting in an old balance being 90 | # returned. This was biting us intermittently in spec runs. 91 | # See http://bugs.mysql.com/bug.php?id=51431 92 | return '' unless Line.connection.adapter_name.match(/mysql/i) 93 | 94 | 'FORCE INDEX (lines_scope_account_id_idx)' 95 | end 96 | 97 | def line_error_message(line, previous_line, previous_balance) 98 | <<-END_OF_MESSAGE.strip_heredoc 99 | ********************************* 100 | Error on line ##{line.id}: balance:#{line.balance} != #{previous_balance} + #{line.amount} 101 | ********************************* 102 | #{previous_line.inspect} 103 | #{line.inspect} 104 | 105 | END_OF_MESSAGE 106 | end 107 | 108 | def cached_balance_correct?(account, log) 109 | DoubleEntry.lock_accounts(account) do 110 | cached_balance = AccountBalance.find_by_account(account).balance 111 | running_balance = account.balance 112 | correct = (cached_balance == running_balance) 113 | log << <<~MESSAGE unless correct 114 | ********************************* 115 | Error on account #{account}: #{cached_balance} (cached balance) != #{running_balance} (running balance) 116 | ********************************* 117 | 118 | MESSAGE 119 | return correct 120 | end 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/generators/double_entry/install/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'action_controller' 2 | require 'generator_spec/test_case' 3 | require 'generators/double_entry/install/install_generator' 4 | 5 | RSpec.describe DoubleEntry::Generators::InstallGenerator do 6 | include GeneratorSpec::TestCase 7 | 8 | # Needed until a new release of generator_spec gem with https://github.com/stevehodgkiss/generator_spec/pull/47 9 | module GeneratorSpec::Matcher 10 | class Migration 11 | def does_not_contain(text) 12 | @does_not_contain << text 13 | end 14 | 15 | def check_contents(file) 16 | contents = ::File.read(file) 17 | 18 | @contents.each do |string| 19 | unless contents.include?(string) 20 | throw :failure, [file, string, contents] 21 | end 22 | end 23 | 24 | @does_not_contain.each do |string| 25 | if contents.include?(string) 26 | throw :failure, [:not, file, string, contents] 27 | end 28 | end 29 | end 30 | end 31 | 32 | class Root 33 | def failure_message 34 | if @failure.is_a?(Array) && @failure[0] == :not 35 | if @failure.length > 2 36 | "Structure should have #{@failure[1]} without #{@failure[2]}. It had:\n#{@failure[3]}" 37 | else 38 | "Structure should not have had #{@failure[1]}, but it did" 39 | end 40 | elsif @failure.is_a?(Array) 41 | "Structure should have #{@failure[0]} with #{@failure[1]}. It had:\n#{@failure[2]}" 42 | else 43 | "Structure should have #{@failure}, but it didn't" 44 | end 45 | end 46 | end 47 | end 48 | 49 | destination File.expand_path('../../../../../tmp/generators', __FILE__) 50 | 51 | before do 52 | prepare_destination 53 | end 54 | 55 | def expect_migration_to_have_structure(&block) 56 | expect(destination_root).to have_structure { 57 | directory 'db' do 58 | directory 'migrate' do 59 | migration 'create_double_entry_tables' do 60 | @does_not_contain = [] 61 | contains 'class CreateDoubleEntryTables' 62 | contains 'create_table "double_entry_account_balances"' 63 | contains 'create_table "double_entry_lines"' 64 | contains 'create_table "double_entry_line_checks"' 65 | instance_eval(&block) 66 | end 67 | end 68 | end 69 | } 70 | end 71 | 72 | RSpec.shared_examples 'with_json_metadata' do 73 | it 'generates the expected migrations' do 74 | expect_migration_to_have_structure do 75 | contains 't.json "metadata"' 76 | does_not_contain 'create_table "double_entry_line_metadata"' 77 | end 78 | end 79 | 80 | it 'generates the expected initializer' do 81 | expect(destination_root).to have_structure { 82 | directory 'config' do 83 | directory 'initializers' do 84 | file 'double_entry.rb' do 85 | contains 'config.json_metadata = true' 86 | end 87 | end 88 | end 89 | } 90 | end 91 | end 92 | 93 | RSpec.shared_examples 'without_json_metadata' do 94 | it 'generates the expected migrations' do 95 | expect_migration_to_have_structure do 96 | contains 'create_table "double_entry_line_metadata"' 97 | contains 'add_index "double_entry_line_metadata"' 98 | does_not_contain 't.json "metadata"' 99 | end 100 | end 101 | 102 | it 'generates the expected initializer' do 103 | expect(destination_root).to have_structure { 104 | directory 'config' do 105 | directory 'initializers' do 106 | file 'double_entry.rb' do 107 | contains 'config.json_metadata = false' 108 | end 109 | end 110 | end 111 | } 112 | end 113 | end 114 | 115 | context 'without arguments' do 116 | before { run_generator } 117 | 118 | examples = ActiveRecord.version.version < '5' ? 'without_json_metadata' : 'with_json_metadata' 119 | include_examples examples 120 | end 121 | 122 | context 'with --json-metadata' do 123 | before { run_generator %w(--json-metadata) } 124 | 125 | examples = ActiveRecord.version.version < '5' ? 'without_json_metadata' : 'with_json_metadata' 126 | include_examples examples 127 | end 128 | 129 | context 'with --no-json-metadata' do 130 | before { run_generator %w(--no-json-metadata) } 131 | 132 | include_examples 'without_json_metadata' 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/double_entry/transfer.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'forwardable' 3 | 4 | module DoubleEntry 5 | class Transfer 6 | class << self 7 | attr_accessor :code_max_length 8 | attr_writer :transfers 9 | 10 | # @api private 11 | def transfers 12 | @transfers ||= Set.new 13 | end 14 | 15 | # @api private 16 | def transfer(amount, options = {}) 17 | fail TransferIsNegative if amount.negative? 18 | from_account = options[:from] 19 | to_account = options[:to] 20 | code = options[:code] 21 | transfers.find!(from_account, to_account, code).process(amount, options) 22 | end 23 | end 24 | 25 | # @api private 26 | class Set 27 | extend Forwardable 28 | delegate [:each, :map] => :all 29 | 30 | def define(attributes) 31 | Transfer.new(attributes).tap do |transfer| 32 | key = [transfer.from, transfer.to, transfer.code] 33 | if _find(*key) 34 | fail DuplicateTransfer 35 | else 36 | backing_collection[key] = transfer 37 | end 38 | end 39 | end 40 | 41 | def find(from_account, to_account, code) 42 | _find(from_account.identifier, to_account.identifier, code) 43 | end 44 | 45 | def find!(from_account, to_account, code) 46 | find(from_account, to_account, code).tap do |transfer| 47 | fail TransferNotAllowed, [from_account.identifier, to_account.identifier, code].inspect unless transfer 48 | end 49 | end 50 | 51 | def all 52 | backing_collection.values 53 | end 54 | 55 | private 56 | 57 | def backing_collection 58 | @backing_collection ||= Hash.new 59 | end 60 | 61 | def _find(from, to, code) 62 | backing_collection[[from, to, code]] 63 | end 64 | end 65 | 66 | attr_reader :code, :from, :to 67 | 68 | def initialize(attributes) 69 | @code = attributes[:code] 70 | @from = attributes[:from] 71 | @to = attributes[:to] 72 | if Transfer.code_max_length && code.length > Transfer.code_max_length 73 | fail TransferCodeTooLongError, 74 | "transfer code '#{code}' is too long. Please limit it to #{Transfer.code_max_length} characters." 75 | end 76 | end 77 | 78 | def process(amount, options) 79 | credit = debit = nil 80 | from_account = options[:from] 81 | to_account = options[:to] 82 | code = options[:code] 83 | detail = options[:detail] 84 | metadata = options[:metadata] 85 | if from_account.scope_identity == to_account.scope_identity && from_account.identifier == to_account.identifier 86 | fail TransferNotAllowed, 'from account and to account are identical' 87 | end 88 | if to_account.currency != from_account.currency 89 | fail MismatchedCurrencies, "Mismatched currency (#{to_account.currency} <> #{from_account.currency})" 90 | end 91 | Locking.lock_accounts(from_account, to_account) do 92 | credit, debit = create_lines(amount, code, detail, from_account, to_account, metadata) 93 | create_line_metadata(credit, debit, metadata) if metadata && !DoubleEntry.config.json_metadata 94 | end 95 | [credit, debit] 96 | end 97 | 98 | def create_lines(amount, code, detail, from_account, to_account, metadata) 99 | credit, debit = Line.new, Line.new 100 | 101 | credit_balance = Locking.balance_for_locked_account(from_account) 102 | debit_balance = Locking.balance_for_locked_account(to_account) 103 | 104 | credit_balance.update_attribute :balance, credit_balance.balance - amount 105 | debit_balance.update_attribute :balance, debit_balance.balance + amount 106 | 107 | credit.amount, debit.amount = -amount, amount 108 | credit.account, debit.account = from_account, to_account 109 | credit.code, debit.code = code, code 110 | credit.detail, debit.detail = detail, detail 111 | credit.balance, debit.balance = credit_balance.balance, debit_balance.balance 112 | credit.metadata, debit.metadata = metadata, metadata if DoubleEntry.config.json_metadata 113 | 114 | credit.partner_account, debit.partner_account = to_account, from_account 115 | 116 | credit.save! 117 | debit.partner_id = credit.id 118 | debit.save! 119 | credit.update_attribute :partner_id, debit.id 120 | [credit, debit] 121 | end 122 | 123 | def create_line_metadata(credit, debit, metadata) 124 | metadata.each_pair do |key, value| 125 | Array(value).each do |each_value| 126 | LineMetadata.create!(line: credit, key: key, value: each_value) 127 | LineMetadata.create!(line: debit, key: key, value: each_value) 128 | end 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # This allows you to limit a spec run to individual examples or groups 48 | # you care about by tagging them with `:focus` metadata. When nothing 49 | # is tagged with `:focus`, all examples get run. RSpec also provides 50 | # aliases for `it`, `describe`, and `context` that include `:focus` 51 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 52 | config.filter_run_when_matching :focus 53 | 54 | # Allows RSpec to persist some state between runs in order to support 55 | # the `--only-failures` and `--next-failure` CLI options. We recommend 56 | # you configure your source control system to ignore this file. 57 | config.example_status_persistence_file_path = "spec/examples.txt" 58 | 59 | # Limits the available syntax to the non-monkey patched syntax that is 60 | # recommended. For more details, see: 61 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 62 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 63 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 64 | config.disable_monkey_patching! 65 | 66 | # This setting enables warnings. It's recommended, but in some cases may 67 | # be too noisy due to issues in dependencies. 68 | config.warnings = true 69 | 70 | # Many RSpec users commonly either run the entire suite or an individual 71 | # file, and it's useful to allow more verbose output when running an 72 | # individual spec file. 73 | if config.files_to_run.one? 74 | # Use the documentation formatter for detailed output, 75 | # unless a formatter has already been configured 76 | # (e.g. via a command-line flag). 77 | config.default_formatter = "doc" 78 | end 79 | 80 | # Print the 10 slowest examples and example groups at the 81 | # end of the spec run, to help surface which specs are running 82 | # particularly slow. 83 | # config.profile_examples = 10 84 | 85 | # Run specs in random order to surface order dependencies. If you find an 86 | # order dependency and want to debug it, you can fix the order by providing 87 | # the seed, which is printed after each run. 88 | # --seed 1234 89 | config.order = :random 90 | 91 | # Seed global randomization in this process using the `--seed` CLI option. 92 | # Setting this allows you to use `--seed` to deterministically reproduce 93 | # test failures related to randomization by passing the same `--seed` value 94 | # as the one that triggered the failure. 95 | Kernel.srand config.seed 96 | end 97 | -------------------------------------------------------------------------------- /lib/double_entry/account.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'forwardable' 3 | 4 | module DoubleEntry 5 | class Account 6 | class << self 7 | attr_accessor :scope_identifier_max_length, :account_identifier_max_length 8 | attr_writer :accounts 9 | 10 | # @api private 11 | def accounts 12 | @accounts ||= Set.new 13 | end 14 | 15 | # @api private 16 | def account(identifier, options = {}) 17 | account = accounts.find(identifier, (options[:scope].present? || options[:scope_identity].present?)) 18 | Instance.new(account: account, scope: options[:scope], scope_identity: options[:scope_identity]) 19 | end 20 | 21 | # @api private 22 | def currency(identifier) 23 | accounts.find_without_scope(identifier).try(:currency) 24 | end 25 | end 26 | 27 | # @api private 28 | class Set 29 | extend Forwardable 30 | 31 | delegate [:each, :map] => :all 32 | 33 | def define(attributes) 34 | Account.new(attributes).tap do |account| 35 | if find_without_scope(account.identifier) 36 | fail DuplicateAccount 37 | else 38 | backing_collection[account.identifier] = account 39 | end 40 | end 41 | end 42 | 43 | def find(identifier, scoped) 44 | found_account = find_without_scope(identifier) 45 | 46 | if found_account && found_account.scoped? == scoped 47 | found_account 48 | else 49 | fail UnknownAccount, "account: #{identifier} scoped?: #{scoped}" 50 | end 51 | end 52 | 53 | def find_without_scope(identifier) 54 | backing_collection[identifier] 55 | end 56 | 57 | def all 58 | backing_collection.values 59 | end 60 | 61 | private 62 | 63 | def backing_collection 64 | @backing_collection ||= Hash.new 65 | end 66 | end 67 | 68 | class Instance 69 | attr_reader :account, :scope 70 | delegate :identifier, :scope_identifier, :scoped?, :positive_only, :negative_only, :currency, to: :account 71 | 72 | def initialize(args) 73 | @account = args[:account] 74 | @scope = args[:scope] 75 | @scope_identity = args[:scope_identity] 76 | ensure_scope_is_valid 77 | end 78 | 79 | def scope_identity 80 | @scope_identity || call_scope_identifier 81 | end 82 | 83 | def call_scope_identifier 84 | scope_identifier.call(scope).to_s if scoped? 85 | end 86 | 87 | # Get the current or historic balance of this account. 88 | # 89 | # @option options :from [Time] 90 | # @option options :to [Time] 91 | # @option options :at [Time] 92 | # @option options :code [Symbol] 93 | # @option options :codes [Array] 94 | # @return [Money] 95 | # 96 | def balance(options = {}) 97 | BalanceCalculator.calculate(self, options) 98 | end 99 | 100 | include Comparable 101 | 102 | def ==(other) 103 | other.is_a?(self.class) && identifier == other.identifier && scope_identity == other.scope_identity 104 | end 105 | 106 | def eql?(other) 107 | self == other 108 | end 109 | 110 | def <=>(other) 111 | [scope_identity.to_s, identifier.to_s] <=> [other.scope_identity.to_s, other.identifier.to_s] 112 | end 113 | 114 | def hash 115 | if scoped? 116 | "#{scope_identity}:#{identifier}".hash 117 | else 118 | identifier.hash 119 | end 120 | end 121 | 122 | def to_s 123 | "\#{Account account: #{identifier} scope: #{scope} currency: #{currency}}" 124 | end 125 | 126 | def inspect 127 | to_s 128 | end 129 | 130 | private 131 | 132 | def ensure_scope_is_valid 133 | identity = scope_identity 134 | if identity && Account.scope_identifier_max_length && identity.length > Account.scope_identifier_max_length 135 | fail ScopeIdentifierTooLongError, 136 | "scope identifier '#{identity}' is too long. Please limit it to #{Account.scope_identifier_max_length} characters." 137 | end 138 | end 139 | end 140 | 141 | attr_reader :identifier, :scope_identifier, :positive_only, :negative_only, :currency 142 | 143 | def initialize(args) 144 | @identifier = args[:identifier] 145 | @scope_identifier = args[:scope_identifier] 146 | @positive_only = args[:positive_only] 147 | @negative_only = args[:negative_only] 148 | @currency = args[:currency] || Money.default_currency 149 | if Account.account_identifier_max_length && identifier.length > Account.account_identifier_max_length 150 | fail AccountIdentifierTooLongError, 151 | "account identifier '#{identifier}' is too long. Please limit it to #{Account.account_identifier_max_length} characters." 152 | end 153 | end 154 | 155 | def scoped? 156 | !!scope_identifier 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/double_entry/line.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module DoubleEntry 4 | # This is the table to end all tables! 5 | # 6 | # Every financial transaction gets two entries in here: one for the source 7 | # account, and one for the destination account. Normal double-entry 8 | # accounting principles are followed. 9 | # 10 | # This is a log table, and should (ideally) never be updated. 11 | # 12 | # ## Indexes 13 | # 14 | # The indexes on this table are carefully chosen, as it's both big and heavily loaded. 15 | # 16 | # ### lines_scope_account_id_idx 17 | # 18 | # ```sql 19 | # ADD INDEX `lines_scope_account_id_idx` (scope, account, id) 20 | # ``` 21 | # 22 | # This is the important one. It's used primarily for querying the current 23 | # balance of an account. eg: 24 | # 25 | # ```sql 26 | # SELECT * FROM `lines` WHERE scope = ? AND account = ? ORDER BY id DESC LIMIT 1 27 | # ``` 28 | # 29 | # ### lines_scope_account_created_at_idx 30 | # 31 | # ```sql 32 | # ADD INDEX `lines_scope_account_created_at_idx` (scope, account, created_at) 33 | # ``` 34 | # 35 | # Used for querying historic balances: 36 | # 37 | # ```sql 38 | # SELECT * FROM `lines` WHERE scope = ? AND account = ? AND created_at < ? ORDER BY id DESC LIMIT 1 39 | # ``` 40 | # 41 | # And for reporting on account changes over a time period: 42 | # 43 | # ```sql 44 | # SELECT SUM(amount) FROM `lines` WHERE scope = ? AND account = ? AND created_at BETWEEN ? AND ? 45 | # ``` 46 | # 47 | # ### lines_account_created_at_idx and lines_account_code_created_at_idx 48 | # 49 | # ```sql 50 | # ADD INDEX `lines_account_created_at_idx` (account, created_at); 51 | # ADD INDEX `lines_account_code_created_at_idx` (account, code, created_at); 52 | # ``` 53 | # 54 | # These two are used for generating reports, which need to sum things 55 | # by account, or account and code, over a particular period. 56 | # 57 | class Line < ActiveRecord::Base 58 | belongs_to :detail, polymorphic: true, required: false 59 | has_many :metadata, class_name: 'DoubleEntry::LineMetadata' unless -> { DoubleEntry.config.json_metadata } 60 | scope :credits, -> { where('amount > 0') } 61 | scope :debits, -> { where('amount < 0') } 62 | scope :with_id_greater_than, ->(id) { where('id > ?', id) } 63 | 64 | def amount 65 | self[:amount] && Money.new(self[:amount], currency) 66 | end 67 | 68 | def amount=(money) 69 | self[:amount] = (money && money.fractional) 70 | end 71 | 72 | def balance 73 | self[:balance] && Money.new(self[:balance], currency) 74 | end 75 | 76 | def balance=(money) 77 | self[:balance] = (money && money.fractional) 78 | end 79 | 80 | def save(**) 81 | check_balance_will_remain_valid 82 | super 83 | end 84 | 85 | def save!(**) 86 | check_balance_will_remain_valid 87 | super 88 | end 89 | 90 | def code=(code) 91 | self[:code] = code.try(:to_s) 92 | code 93 | end 94 | 95 | def code 96 | self[:code].try(:to_sym) 97 | end 98 | 99 | def account=(account) 100 | self[:account] = account.identifier.to_s 101 | self.scope = account.scope_identity 102 | fail 'Missing Account' unless self.account 103 | account 104 | end 105 | 106 | def account 107 | DoubleEntry.account(self[:account].to_sym, scope_identity: scope) 108 | end 109 | 110 | def currency 111 | account.currency if self[:account] 112 | end 113 | 114 | def partner_account=(partner_account) 115 | self[:partner_account] = partner_account.identifier.to_s 116 | self.partner_scope = partner_account.scope_identity 117 | fail 'Missing Partner Account' unless self.partner_account 118 | partner_account 119 | end 120 | 121 | def partner_account 122 | DoubleEntry.account(self[:partner_account].to_sym, scope_identity: partner_scope) 123 | end 124 | 125 | def partner 126 | self.class.find(partner_id) 127 | end 128 | 129 | def pair 130 | if decrease? 131 | [self, partner] 132 | else 133 | [partner, self] 134 | end 135 | end 136 | 137 | def decrease? 138 | amount.negative? 139 | end 140 | 141 | def increase? 142 | amount.positive? 143 | end 144 | 145 | # Query out just the id and created_at fields for lines, without 146 | # instantiating any ActiveRecord objects. 147 | def self.find_id_and_created_at(options) 148 | connection.select_rows <<-SQL 149 | SELECT id, created_at FROM #{Line.quoted_table_name} #{options[:joins]} 150 | WHERE #{sanitize_sql_for_conditions(options[:conditions])} 151 | SQL 152 | end 153 | 154 | private 155 | 156 | def check_balance_will_remain_valid 157 | if account.positive_only && balance.negative? 158 | fail AccountWouldBeSentNegative, account 159 | end 160 | if account.negative_only && balance.positive? 161 | fail AccountWouldBeSentPositiveError, account 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /spec/double_entry/validation/line_check_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | module Validation 4 | RSpec.describe LineCheck do 5 | describe '.last_line_id_checked' do 6 | subject(:last_line_id_checked) { LineCheck.last_line_id_checked } 7 | 8 | context 'Given some checks have been created' do 9 | before do 10 | Timecop.freeze 3.minutes.ago do 11 | LineCheck.create! last_line_id: 100, errors_found: false, log: '' 12 | end 13 | Timecop.freeze 1.minute.ago do 14 | LineCheck.create! last_line_id: 300, errors_found: false, log: '' 15 | end 16 | Timecop.freeze 2.minutes.ago do 17 | LineCheck.create! last_line_id: 200, errors_found: false, log: '' 18 | end 19 | end 20 | 21 | it 'should find the newest LineCheck created (by creation_date)' do 22 | expect(last_line_id_checked).to eq 300 23 | end 24 | end 25 | end 26 | 27 | describe '#perform!' do 28 | subject(:line_check) { LineCheck.perform!(fixer: AccountFixer.new) } 29 | 30 | context 'Given a user with 100 dollars' do 31 | before { create(:user, savings_balance: Money.new(100_00)) } 32 | 33 | context 'And all is consistent' do 34 | context 'And all lines have been checked' do 35 | before { LineCheck.perform!(fixer: AccountFixer.new) } 36 | 37 | it { should be_nil } 38 | 39 | it 'should not persist a new LineCheck' do 40 | expect { line_check }. 41 | to_not change { LineCheck.count } 42 | end 43 | end 44 | 45 | it { should be_instance_of LineCheck } 46 | its(:errors_found) { should eq false } 47 | 48 | it 'should persist the LineCheck' do 49 | line_check 50 | expect(LineCheck.last).to eq line_check 51 | end 52 | end 53 | 54 | context 'And there is a consistency error in lines' do 55 | before { DoubleEntry::Line.order(:id).limit(1).update_all('balance = balance + 1') } 56 | 57 | its(:errors_found) { should be true } 58 | its(:log) { should match(/Error on line/) } 59 | 60 | it 'should correct the running balance' do 61 | expect { line_check }. 62 | to change { DoubleEntry::Line.order(:id).first.balance }. 63 | by Money.new(-1) 64 | end 65 | 66 | context 'And given we ask not to correct the errors' do 67 | it 'should not correct the running balance' do 68 | expect { LineCheck.perform!(fixer: nil) }.not_to change { Line.order(:id).first.balance } 69 | end 70 | end 71 | end 72 | 73 | context 'And there is a consistency error in account balance' do 74 | before { DoubleEntry::AccountBalance.order(:id).limit(1).update_all('balance = balance + 1') } 75 | 76 | its(:errors_found) { should be true } 77 | 78 | its(:log) { should include <<~LOG } 79 | Error on account \#{Account account: savings scope: currency: USD}: 100.01 (cached balance) != 100.00 (running balance) 80 | LOG 81 | 82 | it 'should correct the account balance' do 83 | expect { line_check }. 84 | to change { DoubleEntry::AccountBalance.order(:id).first.balance }. 85 | by Money.new(-1) 86 | end 87 | 88 | context 'And given we ask not to correct the errors' do 89 | it 'should not correct the account balance' do 90 | expect { LineCheck.perform!(fixer: nil) }.not_to change { AccountBalance.order(:id).first.balance } 91 | end 92 | end 93 | end 94 | end 95 | 96 | context 'Given a user with a non default currency balance' do 97 | before { create(:user, bitcoin_balance: Money.new(100_00, 'BTC')) } 98 | its(:errors_found) { should eq false } 99 | context 'And there is a consistency error in lines' do 100 | before { DoubleEntry::Line.order(:id).limit(1).update_all('balance = balance + 1') } 101 | 102 | its(:errors_found) { should eq true } 103 | 104 | its(:log) { should include <<~LOG } 105 | Error on account \#{Account account: btc_test scope: currency: BTC}: -0.00010000 (cached balance) != -0.00009999 (running balance) 106 | LOG 107 | 108 | it 'should correct the running balance' do 109 | expect { line_check }. 110 | to change { DoubleEntry::Line.order(:id).first.balance }. 111 | by Money.new(-1, 'BTC') 112 | end 113 | 114 | context 'And given we ask not to correct the errors' do 115 | it 'should not correct the running balance' do 116 | expect { LineCheck.perform!(fixer: nil) }.not_to change { Line.order(:id).first.balance } 117 | end 118 | end 119 | end 120 | end 121 | 122 | it 'has a table name prefixed with double_entry_' do 123 | expect(LineCheck.table_name).to eq 'double_entry_line_checks' 124 | end 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/double_entry/locking.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | # Lock financial accounts to ensure consistency. 4 | # 5 | # In order to ensure financial transactions always keep track of balances 6 | # consistently, database-level locking is needed. This module takes care of 7 | # it. 8 | # 9 | # See DoubleEntry.lock_accounts and DoubleEntry.transfer for the public interface 10 | # to this stuff. 11 | # 12 | # Locking is done on DoubleEntry::AccountBalance records. If an AccountBalance 13 | # record for an account doesn't exist when you try to lock it, the locking 14 | # code will create one. 15 | # 16 | # script/jack_hammer can be used to run concurrency tests on double_entry to 17 | # validates that locking works properly. 18 | module Locking 19 | include Configurable 20 | 21 | class Configuration 22 | # Set this in your tests if you're using transactional_fixtures, so we know 23 | # not to complain about a containing transaction when you call lock_accounts. 24 | attr_accessor :running_inside_transactional_fixtures 25 | 26 | def initialize #:nodoc: 27 | @running_inside_transactional_fixtures = false 28 | end 29 | end 30 | 31 | # Run the passed in block in a transaction with the given accounts locked for update. 32 | # 33 | # The transaction must be the outermost transaction to ensure data integrity. A 34 | # LockMustBeOutermostTransaction will be raised if it isn't. 35 | def self.lock_accounts(*accounts, &block) 36 | lock = Lock.new(accounts) 37 | 38 | if lock.in_a_locked_transaction? 39 | lock.ensure_locked! 40 | block.call 41 | else 42 | lock.perform_lock(&block) 43 | end 44 | 45 | rescue ActiveRecord::StatementInvalid => exception 46 | if exception.message =~ /lock wait timeout/i 47 | raise LockWaitTimeout 48 | else 49 | raise 50 | end 51 | end 52 | 53 | # Return the account balance record for the given account name if there's a 54 | # lock on it, or raise a LockNotHeld if there isn't. 55 | def self.balance_for_locked_account(account) 56 | Lock.new([account]).balance_for(account) 57 | end 58 | 59 | class Lock 60 | @@locks = {} 61 | 62 | def initialize(accounts) 63 | # Make sure we always lock in the same order, to avoid deadlocks. 64 | @accounts = accounts.flatten.sort 65 | end 66 | 67 | # Lock the given accounts, creating account balance records for them if 68 | # needed. 69 | def perform_lock(&block) 70 | ensure_outermost_transaction! 71 | 72 | unless lock_and_call(&block) 73 | create_missing_account_balances 74 | fail LockDisaster unless lock_and_call(&block) 75 | end 76 | end 77 | 78 | # Return true if we're inside a lock_accounts block. 79 | def in_a_locked_transaction? 80 | !locks.nil? 81 | end 82 | 83 | def ensure_locked! 84 | @accounts.each do |account| 85 | unless lock?(account) 86 | fail LockNotHeld, "No lock held for account: #{account.identifier}, scope #{account.scope}" 87 | end 88 | end 89 | end 90 | 91 | def balance_for(account) 92 | ensure_locked! 93 | 94 | locks[account] 95 | end 96 | 97 | private 98 | 99 | def locks 100 | @@locks[Thread.current.object_id] 101 | end 102 | 103 | def locks=(locks) 104 | @@locks[Thread.current.object_id] = locks 105 | end 106 | 107 | def remove_locks 108 | @@locks.delete(Thread.current.object_id) 109 | end 110 | 111 | # Return true if there's a lock on the given account. 112 | def lock?(account) 113 | in_a_locked_transaction? && locks.key?(account) 114 | end 115 | 116 | # Raise an exception unless we're outside any transactions. 117 | def ensure_outermost_transaction! 118 | minimum_transaction_level = Locking.configuration.running_inside_transactional_fixtures ? 1 : 0 119 | unless AccountBalance.connection.open_transactions <= minimum_transaction_level 120 | fail LockMustBeOutermostTransaction 121 | end 122 | end 123 | 124 | # Start a transaction, grab locks on the given accounts, then call the block 125 | # from within the transaction. 126 | # 127 | # If any account can't be locked (because there isn't a corresponding account 128 | # balance record), don't call the block, and return false. 129 | def lock_and_call 130 | locks_succeeded = nil 131 | AccountBalance.restartable_transaction do 132 | locks_succeeded = AccountBalance.with_restart_on_deadlock { grab_locks } 133 | if locks_succeeded 134 | begin 135 | yield 136 | ensure 137 | remove_locks 138 | end 139 | end 140 | end 141 | locks_succeeded 142 | end 143 | 144 | # Grab a lock on the account balance record for each account. 145 | # 146 | # If all the account balance records exist, set locks to a hash mapping 147 | # accounts to account balances, and return true. 148 | # 149 | # If one or more account balance records don't exist, set 150 | # accounts_with_balances to the corresponding accounts, and return false. 151 | def grab_locks 152 | account_balances = @accounts.map { |account| AccountBalance.find_by_account(account, lock: true) } 153 | 154 | if account_balances.any?(&:nil?) 155 | @accounts_without_balances = @accounts.zip(account_balances). 156 | select { |_account, account_balance| account_balance.nil? }. 157 | collect { |account, _account_balance| account } 158 | false 159 | else 160 | self.locks = Hash[*@accounts.zip(account_balances).flatten] 161 | true 162 | end 163 | end 164 | 165 | # Create all the account_balances for the given accounts. 166 | def create_missing_account_balances 167 | @accounts_without_balances.each do |account| 168 | # Get the initial balance from the lines table. 169 | balance = account.balance 170 | # Try to create the balance record, but ignore it if someone else has done it in the meantime. 171 | AccountBalance.create_ignoring_duplicates!(account: account, balance: balance) 172 | end 173 | end 174 | end 175 | 176 | # Raised when lock_accounts is called inside an existing transaction. 177 | class LockMustBeOutermostTransaction < RuntimeError 178 | end 179 | 180 | # Raised when attempting a transfer on an account that's not locked. 181 | class LockNotHeld < RuntimeError 182 | end 183 | 184 | # Raised if things go horribly, horribly wrong. This should never happen. 185 | class LockDisaster < RuntimeError 186 | end 187 | 188 | # Raised if waiting for locks times out. 189 | class LockWaitTimeout < RuntimeError 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /script/jack_hammer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Run a concurrency test on the double_entry code. 4 | # 5 | # This spawns a bunch of processes, and does random transactions between a set 6 | # of accounts, then validates that all the numbers add up at the end. 7 | # 8 | # You can also tell it to flush our the account balances table at regular 9 | # intervals, to validate that new account balances records get created with the 10 | # correct balances from the lines table. 11 | # 12 | # Run it without arguments to get the usage. 13 | 14 | require 'optparse' 15 | require 'bundler/setup' 16 | require 'logger' 17 | require 'active_record' 18 | require 'database_cleaner' 19 | require 'erb' 20 | require 'yaml' 21 | 22 | support = File.expand_path("../../spec/support/", __FILE__) 23 | 24 | db_engine = ENV['DB'] || 'mysql' 25 | 26 | if db_engine == 'sqlite' 27 | puts "Skipping jackhammer for SQLITE..." 28 | exit 0 29 | end 30 | 31 | database_config_file = File.join(__dir__, '..', 'spec', 'support', 'database.yml') 32 | database_config_raw = File.read(database_config_file) 33 | database_config_yaml = ERB.new(database_config_raw).result 34 | database_config = YAML.load(database_config_yaml) 35 | ActiveRecord::Base.establish_connection(database_config[db_engine]) 36 | require "#{support}/schema" 37 | 38 | lib = File.expand_path('../../lib', __FILE__) 39 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 40 | require 'double_entry' 41 | require 'double_entry/line' 42 | 43 | def parse_options 44 | $account_count = 5 45 | $process_count = 20 46 | $transfer_count = 20000 47 | $balance_flush_count = 1 48 | $use_threads = false 49 | 50 | options = OptionParser.new 51 | 52 | options.on("-a", "--accounts=COUNT", Integer, "Number of accounts (default: #{$account_count})") do |value| 53 | $account_count = value 54 | end 55 | 56 | options.on("-p", "--processes=COUNT", Integer, "Number of processes (default: #{$process_count})") do |value| 57 | $process_count = value 58 | end 59 | 60 | options.on("-t", "--transfers=COUNT", Integer, "Number of transfers (default: #{$transfer_count})") do |value| 61 | $transfer_count = value 62 | end 63 | 64 | options.on("-f", "--flush-balances=COUNT", Integer, "Flush account balances table COUNT times") do |value| 65 | $balance_flush_count = value 66 | end 67 | 68 | options.on("-z", "--threads", "Use threads instead of processes") do |value| 69 | $use_threads = !!value 70 | end 71 | 72 | options.parse(*ARGV) 73 | end 74 | 75 | 76 | def clean_out_database 77 | puts "Cleaning out the database..." 78 | 79 | DatabaseCleaner.clean_with(:truncation) 80 | end 81 | 82 | def create_accounts_and_transfers 83 | puts "Setting up #{$account_count} accounts..." 84 | 85 | DoubleEntry.configure do |config| 86 | 87 | # Create the accounts. 88 | config.define_accounts do |accounts| 89 | scope = ->(x) { x } 90 | $account_count.times do |i| 91 | accounts.define(identifier: :"account-#{i}", scope_identifier: scope) 92 | end 93 | end 94 | 95 | # Create all the possible transfers. 96 | config.define_transfers do |transfers| 97 | config.accounts.each do |from| 98 | config.accounts.each do |to| 99 | transfers.define(from: from.identifier, to: to.identifier, code: :test) 100 | end 101 | end 102 | end 103 | 104 | # Find account instances so we have something to work with. 105 | $accounts = config.accounts.map do |account| 106 | DoubleEntry.account(account.identifier, scope: 1) 107 | end 108 | end 109 | end 110 | 111 | 112 | def run_tests 113 | puts "Spawning #{$process_count} processes..." 114 | 115 | iterations_per_process = [ ($transfer_count / $process_count / $balance_flush_count), 1 ].max 116 | 117 | $balance_flush_count.times do 118 | puts "Flushing balances" 119 | DoubleEntry::AccountBalance.delete_all 120 | ActiveRecord::Base.connection_pool.disconnect! 121 | 122 | if $use_threads 123 | puts "Using threads as workers" 124 | threads = [] 125 | $process_count.times do |process_num| 126 | threads << Thread.new { run_process(iterations_per_process, process_num) } 127 | end 128 | 129 | threads.each(&:join) 130 | else 131 | puts "Using processes as workers" 132 | pids = [] 133 | $process_count.times do |process_num| 134 | pids << fork { run_process(iterations_per_process, process_num) } 135 | end 136 | 137 | pids.each {|pid| Process.wait2(pid) } 138 | end 139 | end 140 | end 141 | 142 | 143 | def run_process(iterations, process_num) 144 | srand # Seed the random number generator separately for each process. 145 | 146 | puts "Process #{process_num} running #{iterations} transfers..." 147 | 148 | iterations.times do |i| 149 | account_a = $accounts.sample 150 | account_b = ($accounts - [account_a]).sample 151 | amount = rand(1000) + 1 152 | 153 | DoubleEntry.transfer(Money.new(amount), from: account_a, to: account_b, code: :test) 154 | 155 | puts "Process #{process_num} completed #{i+1} transfers" if (i+1) % 100 == 0 156 | end 157 | end 158 | 159 | def reconciled?(account) 160 | scoped_lines = DoubleEntry::Line.where(account: "#{account.identifier}") 161 | scoped_lines = scoped_lines.where(scope: "#{account.scope_identity}") if account.scoped? 162 | sum_of_amounts = scoped_lines.sum(:amount) 163 | final_balance = scoped_lines.order(:id).last[:balance] 164 | cached_balance = DoubleEntry::AccountBalance.find_by_account(account)[:balance] 165 | final_balance == sum_of_amounts && final_balance == cached_balance 166 | end 167 | 168 | def reconcile 169 | error_count = 0 170 | puts "Reconciling..." 171 | 172 | if DoubleEntry::Line.count == $transfer_count * 2 173 | puts "All the Line records were written, FTW!" 174 | else 175 | puts "Not enough Line records written. :(" 176 | error_count += 1 177 | end 178 | 179 | if $accounts.all?(&method(:reconciled?)) 180 | puts "All accounts reconciled, FTW!" 181 | else 182 | $accounts.each do |account| 183 | unless reconciled?(account) 184 | puts "Account #{account.identifier} failed to reconcile. :(" 185 | 186 | # See http://bugs.mysql.com/bug.php?id=51431 187 | use_index = if DoubleEntry::Line.connection.adapter_name.match /mysql/i 188 | "USE INDEX (lines_scope_account_id_idx)" 189 | else 190 | "" 191 | end 192 | 193 | rows = ActiveRecord::Base.connection.select_all(<<-SQL) 194 | SELECT id, amount, balance 195 | FROM #{DoubleEntry::Line.quoted_table_name} #{use_index} 196 | WHERE scope = '#{account.scope_identity}' 197 | AND account = '#{account.identifier}' 198 | ORDER BY id 199 | SQL 200 | 201 | rows.each_cons(2) do |a, b| 202 | if a["balance"].to_i + b["amount"].to_i != b["balance"].to_i 203 | puts "Bad lines entry id = #{b['id']}" 204 | error_count += 1 205 | end 206 | end 207 | end 208 | end 209 | end 210 | 211 | error_count == 0 212 | end 213 | 214 | 215 | parse_options 216 | clean_out_database 217 | create_accounts_and_transfers 218 | run_tests 219 | 220 | if reconcile 221 | puts "Done successfully :)" 222 | exit 0 223 | else 224 | puts "Done with errors :(" 225 | exit 1 226 | end 227 | -------------------------------------------------------------------------------- /lib/double_entry.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'active_record' 3 | require 'active_record/locking_extensions' 4 | require 'active_record/locking_extensions/log_subscriber' 5 | require 'active_support/all' 6 | require 'money' 7 | require 'rails/railtie' 8 | 9 | require 'double_entry/version' 10 | require 'double_entry/errors' 11 | require 'double_entry/configurable' 12 | require 'double_entry/configuration' 13 | require 'double_entry/account' 14 | require 'double_entry/account_balance' 15 | require 'double_entry/balance_calculator' 16 | require 'double_entry/locking' 17 | require 'double_entry/transfer' 18 | require 'double_entry/validation' 19 | 20 | # Keep track of all the monies! 21 | # 22 | # This module provides the public interfaces for everything to do with 23 | # transferring money around the system. 24 | module DoubleEntry 25 | class Railtie < ::Rails::Railtie 26 | # So we can access user config from initializer in their app 27 | config.after_initialize do 28 | unless DoubleEntry.config.json_metadata 29 | require 'double_entry/line_metadata' 30 | end 31 | require 'double_entry/line' 32 | end 33 | end 34 | 35 | class << self 36 | # Get the particular account instance with the provided identifier and 37 | # scope. 38 | # 39 | # @example Obtain the 'cash' account for a user 40 | # DoubleEntry.account(:cash, scope: user) 41 | # @param [Symbol] identifier The symbol identifying the desired account. As 42 | # specified in the account configuration. 43 | # @option options :scope Limit the account to the given scope. As specified 44 | # in the account configuration. 45 | # @return [DoubleEntry::Account::Instance] 46 | # @raise [DoubleEntry::UnknownAccount] The described account has not been 47 | # configured. It is unknown. 48 | # @raise [DoubleEntry::AccountScopeMismatchError] The provided scope does not 49 | # match that defined on the account. 50 | # 51 | def account(identifier, options = {}) 52 | Account.account(identifier, options) 53 | end 54 | 55 | # Transfer money from one account to another. 56 | # 57 | # Only certain transfers are allowed. Define legal transfers in your 58 | # configuration file. 59 | # 60 | # If you're doing more than one transfer in one hit, or you're doing other 61 | # database operations along with your transfer, you'll need to use the 62 | # lock_accounts method. 63 | # 64 | # @example Transfer $20 from a user's checking to savings account 65 | # checking_account = DoubleEntry.account(:checking, scope: user) 66 | # savings_account = DoubleEntry.account(:savings, scope: user) 67 | # credit, debit = DoubleEntry.transfer( 68 | # 20.dollars, 69 | # from: checking_account, 70 | # to: savings_account, 71 | # code: :save, 72 | # ) 73 | # @param [Money] amount The quantity of money to transfer from one account 74 | # to the other. 75 | # @option options :from [DoubleEntry::Account::Instance] Transfer money out 76 | # of this account. 77 | # @option options :to [DoubleEntry::Account::Instance] Transfer money into 78 | # this account. 79 | # @option options :code [Symbol] The application specific code for this 80 | # type of transfer. As specified in the transfer configuration. 81 | # @option options :detail [ActiveRecord::Base] ActiveRecord model 82 | # associated (via a polymorphic association) with the transfer. 83 | # @return [[Line, Line]] The credit & debit (in that order) created by the transfer 84 | # @raise [DoubleEntry::TransferIsNegative] The amount is less than zero. 85 | # @raise [DoubleEntry::TransferNotAllowed] A transfer between these 86 | # accounts with the provided code is not allowed. Check configuration. 87 | # 88 | def transfer(amount, options = {}) 89 | Transfer.transfer(amount, options) 90 | end 91 | 92 | # Get the current or historic balance of an account. 93 | # 94 | # @example Obtain the current balance of my checking account 95 | # checking_account = DoubleEntry.account(:checking, scope: user) 96 | # DoubleEntry.balance(checking_account) 97 | # @example Obtain the current balance of my checking account (without account or user model) 98 | # DoubleEntry.balance(:checking, scope: user_id) 99 | # @example Obtain a historic balance of my checking account 100 | # checking_account = DoubleEntry.account(:checking, scope: user) 101 | # DoubleEntry.balance(checking_account, at: Time.new(2012, 1, 1)) 102 | # @example Obtain the net balance of my checking account during may 103 | # checking_account = DoubleEntry.account(:checking, scope: user) 104 | # DoubleEntry.balance( 105 | # checking_account, 106 | # from: Time.new(2012, 5, 1, 0, 0, 0), 107 | # to: Time.new(2012, 5, 31, 23, 59, 59), 108 | # ) 109 | # @example Obtain the balance of salary deposits made to my checking account during may 110 | # checking_account = DoubleEntry.account(:checking, scope: user) 111 | # DoubleEntry.balance( 112 | # checking_account, 113 | # code: :salary, 114 | # from: Time.new(2012, 5, 1, 0, 0, 0), 115 | # to: Time.new(2012, 5, 31, 23, 59, 59), 116 | # ) 117 | # @example Obtain the balance of salary & lottery deposits made to my checking account during may 118 | # checking_account = DoubleEntry.account(:checking, scope: user) 119 | # DoubleEntry.balance( 120 | # checking_account, 121 | # codes: [ :salary, :lottery ], 122 | # from: Time.new(2012, 5, 1, 0, 0, 0), 123 | # to: Time.new(2012, 5, 31, 23, 59, 59), 124 | # ) 125 | # @param [DoubleEntry::Account:Instance, Symbol] account Find the balance 126 | # for this account 127 | # @option options :scope [Object] The scope identifier of the account (only 128 | # needed if the provided account is a symbol). 129 | # @option options :from [Time] used with :to, consider only the time 130 | # between these dates 131 | # @option options :to [Time] used with :from, consider only the time 132 | # between these dates 133 | # @option options :at [Time] obtain the account balance at this time 134 | # @option options :code [Symbol] consider only the transfers with this code 135 | # @option options :codes [Array] consider only the transfers with 136 | # these codes 137 | # @return [Money] The balance 138 | # @raise [DoubleEntry::UnknownAccount] The described account has not been 139 | # configured. It is unknown. 140 | # @raise [DoubleEntry::AccountScopeMismatchError] The provided scope does not 141 | # match that defined on the account. 142 | def balance(account, options = {}) 143 | account = account(account, options) if account.is_a? Symbol 144 | BalanceCalculator.calculate(account, options) 145 | end 146 | 147 | # Lock accounts in preparation for transfers. 148 | # 149 | # This creates a transaction, and uses database-level locking to ensure 150 | # that we're the only ones who can transfer to or from the given accounts 151 | # for the duration of the transaction. 152 | # 153 | # @example Lock the savings and checking accounts for a user 154 | # checking_account = DoubleEntry.account(:checking, scope: user) 155 | # savings_account = DoubleEntry.account(:savings, scope: user) 156 | # DoubleEntry.lock_accounts(checking_account, savings_account) do 157 | # # ... 158 | # end 159 | # @yield Hold the locks while the provided block is processed. 160 | # @raise [DoubleEntry::Locking::LockMustBeOutermostTransaction] 161 | # The transaction must be the outermost database transaction 162 | # 163 | def lock_accounts(*accounts, &block) 164 | Locking.lock_accounts(*accounts, &block) 165 | end 166 | 167 | # @api private 168 | def table_name_prefix 169 | 'double_entry_' 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/double_entry/locking_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | RSpec.describe DoubleEntry::Locking do 4 | before do 5 | @config_accounts = DoubleEntry.config.accounts 6 | @config_transfers = DoubleEntry.config.transfers 7 | DoubleEntry.config.accounts = DoubleEntry::Account::Set.new 8 | DoubleEntry.config.transfers = DoubleEntry::Transfer::Set.new 9 | end 10 | 11 | after do 12 | DoubleEntry.config.accounts = @config_accounts 13 | DoubleEntry.config.transfers = @config_transfers 14 | end 15 | 16 | before do 17 | scope = ->(x) { x } 18 | 19 | DoubleEntry.configure do |config| 20 | config.define_accounts do |accounts| 21 | accounts.define(identifier: :account_a, scope_identifier: scope) 22 | accounts.define(identifier: :account_b, scope_identifier: scope) 23 | accounts.define(identifier: :account_c, scope_identifier: scope) 24 | accounts.define(identifier: :account_d, scope_identifier: scope) 25 | accounts.define(identifier: :account_e) 26 | end 27 | 28 | config.define_transfers do |transfers| 29 | transfers.define(from: :account_a, to: :account_b, code: :test) 30 | transfers.define(from: :account_c, to: :account_d, code: :test) 31 | end 32 | end 33 | 34 | @account_a = DoubleEntry.account(:account_a, scope: '1') 35 | @account_b = DoubleEntry.account(:account_b, scope: '2') 36 | @account_c = DoubleEntry.account(:account_c, scope: '3') 37 | @account_d = DoubleEntry.account(:account_d, scope: '4') 38 | @account_e = DoubleEntry.account(:account_e) 39 | end 40 | 41 | it 'creates missing account balance records' do 42 | expect do 43 | DoubleEntry::Locking.lock_accounts(@account_a) {} 44 | end.to change(DoubleEntry::AccountBalance, :count).by(1) 45 | 46 | account_balance = DoubleEntry::AccountBalance.find_by_account(@account_a) 47 | expect(account_balance).to_not be_nil 48 | expect(account_balance.balance).to eq Money.new(0) 49 | end 50 | 51 | it 'takes the balance for new account balance records from the lines table' do 52 | DoubleEntry::Line.create!( 53 | account: @account_a, 54 | partner_account: @account_b, 55 | amount: Money.new(3_00), 56 | balance: Money.new(3_00), 57 | code: :test, 58 | ) 59 | DoubleEntry::Line.create!( 60 | account: @account_a, 61 | partner_account: @account_b, 62 | amount: Money.new(7_00), 63 | balance: Money.new(10_00), 64 | code: :test, 65 | ) 66 | 67 | expect do 68 | DoubleEntry::Locking.lock_accounts(@account_a) {} 69 | end.to change(DoubleEntry::AccountBalance, :count).by(1) 70 | 71 | account_balance = DoubleEntry::AccountBalance.find_by_account(@account_a) 72 | expect(account_balance).to_not be_nil 73 | expect(account_balance.balance).to eq Money.new(10_00) 74 | end 75 | 76 | it 'prohibits locking inside a regular transaction' do 77 | expect do 78 | DoubleEntry::AccountBalance.transaction do 79 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 80 | end 81 | end 82 | end.to raise_error(DoubleEntry::Locking::LockMustBeOutermostTransaction) 83 | end 84 | 85 | it 'prohibits a transfer inside a regular transaction' do 86 | expect do 87 | DoubleEntry::AccountBalance.transaction do 88 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 89 | end 90 | end.to raise_error(DoubleEntry::Locking::LockMustBeOutermostTransaction) 91 | end 92 | 93 | it "allows a transfer inside a lock if we've locked the transaction accounts" do 94 | expect do 95 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 96 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 97 | end 98 | end.to_not raise_error 99 | end 100 | 101 | it "does not allow a transfer inside a lock if the right locks aren't held" do 102 | expect do 103 | DoubleEntry::Locking.lock_accounts(@account_a, @account_c) do 104 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 105 | end 106 | end.to raise_error(DoubleEntry::Locking::LockNotHeld, 'No lock held for account: account_b, scope 2') 107 | end 108 | 109 | it 'allows nested locks if the outer lock locks all the accounts' do 110 | expect do 111 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 112 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) {} 113 | end 114 | end.to_not raise_error 115 | end 116 | 117 | it "prohibits nested locks if the out lock doesn't lock all the accounts" do 118 | expect do 119 | DoubleEntry::Locking.lock_accounts(@account_a) do 120 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) {} 121 | end 122 | end.to raise_error(DoubleEntry::Locking::LockNotHeld, 'No lock held for account: account_b, scope 2') 123 | end 124 | 125 | it 'rolls back a locking transaction' do 126 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 127 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 128 | fail ActiveRecord::Rollback 129 | end 130 | expect(DoubleEntry.balance(@account_a)).to eq Money.new(0) 131 | expect(DoubleEntry.balance(@account_b)).to eq Money.new(0) 132 | end 133 | 134 | it "rolls back a locking transaction if there's an exception" do 135 | expect do 136 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 137 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 138 | fail 'Yeah, right' 139 | end 140 | end.to raise_error('Yeah, right') 141 | expect(DoubleEntry.balance(@account_a)).to eq Money.new(0) 142 | expect(DoubleEntry.balance(@account_b)).to eq Money.new(0) 143 | end 144 | 145 | it 'allows locking a scoped account and a non scoped account' do 146 | expect do 147 | DoubleEntry::Locking.lock_accounts(@account_d, @account_e) {} 148 | end.to_not raise_error 149 | end 150 | 151 | context 'handling ActiveRecord::StatementInvalid errors' do 152 | context 'non lock wait timeout errors' do 153 | let(:error) { ActiveRecord::StatementInvalid.new('some other error') } 154 | before do 155 | allow(DoubleEntry::AccountBalance).to receive(:with_restart_on_deadlock). 156 | and_raise(error) 157 | end 158 | 159 | it 're-raises the ActiveRecord::StatementInvalid error' do 160 | expect do 161 | DoubleEntry::Locking.lock_accounts(@account_d, @account_e) {} 162 | end.to raise_error(error) 163 | end 164 | end 165 | 166 | context 'lock wait timeout errors' do 167 | before do 168 | allow(DoubleEntry::AccountBalance).to receive(:with_restart_on_deadlock). 169 | and_raise(ActiveRecord::StatementInvalid, 'lock wait timeout') 170 | end 171 | 172 | it 'raises a LockWaitTimeout error' do 173 | expect do 174 | DoubleEntry::Locking.lock_accounts(@account_d, @account_e) {} 175 | end.to raise_error(DoubleEntry::Locking::LockWaitTimeout) 176 | end 177 | end 178 | end 179 | 180 | # sqlite cannot handle these cases so they don't run when DB=sqlite 181 | describe 'concurrent locking', unless: ENV['DB'] == 'sqlite' do 182 | it 'allows multiple threads to lock at the same time' do 183 | expect do 184 | threads = [] 185 | 186 | threads << Thread.new do 187 | sleep 0.05 188 | DoubleEntry::Locking.lock_accounts(@account_a, @account_b) do 189 | DoubleEntry.transfer(Money.new(10_00), from: @account_a, to: @account_b, code: :test) 190 | end 191 | end 192 | 193 | threads << Thread.new do 194 | DoubleEntry::Locking.lock_accounts(@account_c, @account_d) do 195 | sleep 0.1 196 | DoubleEntry.transfer(Money.new(10_00), from: @account_c, to: @account_d, code: :test) 197 | end 198 | end 199 | 200 | threads.each(&:join) 201 | end.to_not raise_error 202 | end 203 | 204 | it 'allows multiple threads to lock accounts without balances at the same time' do 205 | threads = [] 206 | expect do 207 | threads << Thread.new { DoubleEntry::Locking.lock_accounts(@account_a, @account_b) { sleep 0.1 } } 208 | threads << Thread.new { DoubleEntry::Locking.lock_accounts(@account_c, @account_d) { sleep 0.1 } } 209 | 210 | threads.each(&:join) 211 | end.to_not raise_error 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /spec/double_entry/transfer_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module DoubleEntry 3 | RSpec.describe Transfer do 4 | describe '::new' do 5 | context 'given a code_max_length of 47' do 6 | before { Transfer.code_max_length = 47 } 7 | after { Transfer.code_max_length = nil } 8 | 9 | context 'given a code 47 characters in length' do 10 | let(:code) { 'xxxxxxxxxxxxxxxx 47 characters xxxxxxxxxxxxxxxx' } 11 | specify do 12 | expect { Transfer.new(code: code) }.to_not raise_error 13 | end 14 | end 15 | 16 | context 'given a code 48 characters in length' do 17 | let(:code) { 'xxxxxxxxxxxxxxxx 48 characters xxxxxxxxxxxxxxxxx' } 18 | specify do 19 | expect { Transfer.new(code: code) }.to raise_error TransferCodeTooLongError, /'#{code}'/ 20 | end 21 | end 22 | end 23 | end 24 | 25 | describe '::transfer' do 26 | let(:amount) { Money.new(10_00) } 27 | let(:user) { create(:user) } 28 | let(:test) { DoubleEntry.account(:test, scope: user) } 29 | let(:savings) { DoubleEntry.account(:savings, scope: user) } 30 | let(:new_lines) { Line.all[-2..-1] } 31 | 32 | subject(:transfer) { Transfer.transfer(amount, options) } 33 | 34 | context 'without metadata' do 35 | let(:options) { { from: test, to: savings, code: :bonus } } 36 | 37 | it 'creates lines' do 38 | expect { transfer }.to change { Line.count }.by 2 39 | end 40 | 41 | it 'returns the credit & debit' do 42 | credit, debit = transfer 43 | expect(credit).to be_a Line 44 | expect(credit.amount).to eq -amount 45 | 46 | expect(debit).to be_a Line 47 | expect(debit.amount).to eq amount 48 | end 49 | 50 | context 'with config.json_metadata = true', skip: ActiveRecord.version.version < '5' do 51 | around do |example| 52 | DoubleEntry.config.json_metadata = true 53 | example.run 54 | DoubleEntry.config.json_metadata = false 55 | end 56 | 57 | it 'does not create metadata lines' do 58 | expect { transfer }.not_to change { LineMetadata.count } 59 | end 60 | 61 | it 'does not attach metadata to the lines' do 62 | transfer 63 | new_lines.each do |line| 64 | expect(line.metadata).to be_blank 65 | end 66 | end 67 | end 68 | 69 | context 'with config.json_metadata = false' do 70 | it 'does not create metadata lines' do 71 | expect { transfer }.not_to change { LineMetadata.count } 72 | end 73 | 74 | it 'does not attach metadata to the lines', skip: ActiveRecord.version.version < '5' do 75 | transfer 76 | new_lines.each do |line| 77 | expect(line.metadata).to be_blank 78 | end 79 | end 80 | end 81 | end 82 | 83 | context 'with metadata' do 84 | let(:options) { { from: test, to: savings, code: :bonus, metadata: { country: 'AU', tax: 'GST' } } } 85 | 86 | context 'with config.json_metadata = true', skip: ActiveRecord.version.version < '5' do 87 | around do |example| 88 | DoubleEntry.config.json_metadata = true 89 | example.run 90 | DoubleEntry.config.json_metadata = false 91 | end 92 | 93 | it 'does not create metadata lines' do 94 | expect { transfer }.not_to change { LineMetadata.count } 95 | end 96 | 97 | it 'stores the first key/value pair' do 98 | transfer 99 | expect(new_lines.count { |line| line.metadata['country'] == 'AU' }).to be 2 100 | end 101 | 102 | it 'stores another key/value pair' do 103 | transfer 104 | expect(new_lines.count { |line| line.metadata['tax'] == 'GST' }).to be 2 105 | end 106 | end 107 | 108 | context 'with config.json_metadata = false' do 109 | let(:new_metadata) { LineMetadata.all[-4..-1] } 110 | 111 | it 'creates metadata lines' do 112 | expect { transfer }.to change { LineMetadata.count }.by 4 113 | end 114 | 115 | it 'associates the metadata lines with the transfer lines' do 116 | transfer 117 | expect(new_metadata.count { |meta| meta.line == new_lines.first }).to be 2 118 | expect(new_metadata.count { |meta| meta.line == new_lines.last }).to be 2 119 | end 120 | 121 | it 'stores the first key/value pair' do 122 | transfer 123 | 124 | countries = new_metadata.select { |meta| meta.key == :country } 125 | expect(countries.size).to be 2 126 | expect(countries.count { |meta| meta.value == 'AU' }).to be 2 127 | end 128 | 129 | it 'associates the first key/value pair with both lines' do 130 | transfer 131 | countries = new_metadata.select { |meta| meta.key == :country } 132 | expect(countries.map(&:line).uniq.size).to be 2 133 | end 134 | 135 | it 'stores another key/value pair' do 136 | transfer 137 | taxes = new_metadata.select { |meta| meta.key == :tax } 138 | expect(taxes.size).to be 2 139 | expect(taxes.count { |meta| meta.value == 'GST' }).to be 2 140 | end 141 | 142 | it 'does not attach metadata to the lines', skip: ActiveRecord.version.version < '5' do 143 | transfer 144 | new_lines.each do |line| 145 | expect(line.metadata).to be_blank 146 | end 147 | end 148 | end 149 | end 150 | 151 | context 'metadata with multiple values in array for one key' do 152 | let(:options) { { from: test, to: savings, code: :bonus, metadata: { tax: ['GST', 'VAT'] } } } 153 | 154 | context 'with config.json_metadata = true', skip: ActiveRecord.version.version < '5' do 155 | around do |example| 156 | DoubleEntry.config.json_metadata = true 157 | example.run 158 | DoubleEntry.config.json_metadata = false 159 | end 160 | 161 | it 'does not create metadata lines' do 162 | expect { transfer }.not_to change { LineMetadata.count } 163 | end 164 | 165 | it 'stores both values to the same key' do 166 | transfer 167 | expect(new_lines.count { |line| line.metadata['tax'] == ['GST', 'VAT'] }).to be 2 168 | end 169 | end 170 | 171 | context 'with config.json_metadata = false' do 172 | let(:new_metadata) { LineMetadata.all[-4..-1] } 173 | 174 | it 'creates metadata lines' do 175 | expect { transfer }.to change { LineMetadata.count }.by 4 176 | end 177 | 178 | it 'associates the metadata lines with the transfer lines' do 179 | transfer 180 | expect(new_metadata.count { |meta| meta.line == new_lines.first }).to be 2 181 | expect(new_metadata.count { |meta| meta.line == new_lines.last }).to be 2 182 | end 183 | 184 | it 'stores both values to the same key' do 185 | transfer 186 | taxes = new_metadata.select { |meta| meta.key == :tax } 187 | expect(taxes.size).to be 4 188 | expect(taxes.count { |meta| meta.value == 'GST' }).to be 2 189 | expect(taxes.map(&:line).uniq.size).to be 2 190 | end 191 | 192 | it 'does not attach metadata to the lines', skip: ActiveRecord.version.version < '5' do 193 | transfer 194 | new_lines.each do |line| 195 | expect(line.metadata).to be_blank 196 | end 197 | end 198 | end 199 | end 200 | end 201 | 202 | describe Transfer::Set do 203 | subject(:set) { described_class.new } 204 | 205 | before do 206 | set.define( 207 | code: :transfer_code, 208 | from: from_account.identifier, 209 | to: to_account.identifier, 210 | ) 211 | 212 | set.define( 213 | code: :another_transfer_code, 214 | from: from_account.identifier, 215 | to: to_account.identifier, 216 | ) 217 | end 218 | 219 | let(:from_account) { instance_double(Account, identifier: :from) } 220 | let(:to_account) { instance_double(Account, identifier: :to) } 221 | 222 | describe '#find' do 223 | it 'finds the transfers' do 224 | first_transfer = set.find(from_account, to_account, :transfer_code) 225 | second_transfer = set.find(from_account, to_account, :another_transfer_code) 226 | 227 | expect(first_transfer).to be_a Transfer 228 | expect(first_transfer.code).to eq :transfer_code 229 | expect(first_transfer.from).to eq from_account.identifier 230 | expect(first_transfer.to).to eq to_account.identifier 231 | 232 | expect(second_transfer).to be_a Transfer 233 | expect(second_transfer.code).to eq :another_transfer_code 234 | expect(second_transfer.from).to eq from_account.identifier 235 | expect(second_transfer.to).to eq to_account.identifier 236 | end 237 | 238 | it 'returns nothing when searching for undefined transfers' do 239 | undefined_transfer = set.find(to_account, from_account, :transfer_code) 240 | 241 | expect(undefined_transfer).to eq nil 242 | end 243 | end 244 | 245 | describe '#find!' do 246 | it 'also finds the transfers' do 247 | first_transfer = set.find!(from_account, to_account, :transfer_code) 248 | second_transfer = set.find!(from_account, to_account, :another_transfer_code) 249 | 250 | expect(first_transfer).to be_a Transfer 251 | expect(first_transfer.code).to eq :transfer_code 252 | expect(first_transfer.from).to eq from_account.identifier 253 | expect(first_transfer.to).to eq to_account.identifier 254 | 255 | expect(second_transfer).to be_a Transfer 256 | expect(second_transfer.code).to eq :another_transfer_code 257 | expect(second_transfer.from).to eq from_account.identifier 258 | expect(second_transfer.to).to eq to_account.identifier 259 | end 260 | 261 | it 'raises an error when searching for undefined transfers' do 262 | expect { set.find!(to_account, from_account, :transfer_code) } 263 | .to raise_error(TransferNotAllowed) 264 | end 265 | end 266 | end 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DoubleEntry 2 | 3 | 4 | [![License MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/envato/double_entry/blob/master/LICENSE.md) 5 | [![Gem Version](https://badge.fury.io/rb/double_entry.svg)](http://badge.fury.io/rb/double_entry) 6 | [![Build Status](https://github.com/envato/double_entry/actions/workflows/ci-workflow.yml/badge.svg)](https://github.com/envato/double_entry/actions/workflows/ci-workflow.yml) 7 | [![Code Climate](https://codeclimate.com/github/envato/double_entry/badges/gpa.svg)](https://codeclimate.com/github/envato/double_entry) 8 | 9 | ![Show me the Money](http://24.media.tumblr.com/tumblr_m3bwbqNJIG1rrgbmqo1_500.gif) 10 | 11 | Keep track of all the monies! 12 | 13 | DoubleEntry is an accounting system based on the principles of a 14 | [Double-entry Bookkeeping](http://en.wikipedia.org/wiki/Double-entry_bookkeeping_system) 15 | system. While this gem acts like a double-entry bookkeeping system, as it creates 16 | two entries in the database for each transfer, it does *not* enforce accounting rules, other than optionally ensuring a balance is positive, and through an allowlist of approved transfers. 17 | 18 | DoubleEntry uses the [Money gem](https://github.com/RubyMoney/money) to encapsulate operations on currency values. 19 | 20 | ## Compatibility 21 | 22 | DoubleEntry is tested against: 23 | 24 | Ruby 25 | * 3.3.x 26 | * 3.2.x 27 | * 3.1.x 28 | * 3.0.x 29 | 30 | Rails 31 | * 7.1.x 32 | * 7.0.x 33 | * 6.1.x 34 | 35 | Databases 36 | * MySQL 37 | * PostgreSQL 38 | * SQLite 39 | 40 | ## Installation 41 | 42 | In your application's `Gemfile`, add: 43 | 44 | ```ruby 45 | gem 'double_entry' 46 | ``` 47 | 48 | Download and install the gem with Bundler: 49 | 50 | ```sh 51 | bundle 52 | ``` 53 | 54 | Generate Rails schema migrations for the required tables: 55 | 56 | > The default behavior is to store metadata in a json(b) column rather than a separate `double_entry_line_metadata` table. If you would like the old (1.x) behavior, you can add `--no-json-metadata`. 57 | 58 | ```sh 59 | rails generate double_entry:install 60 | ``` 61 | 62 | Update the local database: 63 | 64 | ```sh 65 | rake db:migrate 66 | ``` 67 | 68 | 69 | ## Interface 70 | 71 | The entire API for recording financial transactions is available through a few 72 | methods in the [DoubleEntry](lib/double_entry.rb) module. For full details on 73 | what the API provides, please view the documentation on these methods. 74 | 75 | A configuration file should be used to define a set of accounts, and potential 76 | transfers between those accounts. See the Configuration section for more details. 77 | 78 | 79 | ### Accounts 80 | 81 | Money is kept in Accounts. 82 | 83 | Each Account has a scope, which is used to subdivide the account into smaller 84 | accounts. For example, an account can be scoped by user to ensure that each 85 | user has their own individual account. 86 | 87 | Scoping accounts is recommended. Unscoped accounts may perform more slowly 88 | than scoped accounts due to lock contention. 89 | 90 | To get a particular account: 91 | 92 | ```ruby 93 | account = DoubleEntry.account(:spending, scope: user) 94 | ``` 95 | 96 | (This actually returns an Account::Instance object.) 97 | 98 | See [DoubleEntry::Account](lib/double_entry/account.rb) for more info. 99 | 100 | 101 | ### Balances 102 | 103 | Calling: 104 | 105 | ```ruby 106 | account.balance 107 | ``` 108 | 109 | will return the current balance for an account as a Money object. 110 | 111 | 112 | ### Transfers 113 | 114 | To transfer money between accounts: 115 | 116 | ```ruby 117 | DoubleEntry.transfer( 118 | Money.new(20_00), 119 | from: one_account, 120 | to: another_account, 121 | code: :a_business_code_for_this_type_of_transfer, 122 | ) 123 | ``` 124 | 125 | The possible transfers, and their codes, should be defined in the configuration. 126 | 127 | See [DoubleEntry::Transfer](lib/double_entry/transfer.rb) for more info. 128 | 129 | ### Metadata 130 | 131 | You may associate arbitrary metadata with transfers, for example: 132 | 133 | ```ruby 134 | DoubleEntry.transfer( 135 | Money.new(20_00), 136 | from: one_account, 137 | to: another_account, 138 | code: :a_business_code_for_this_type_of_transfer, 139 | metadata: {key1: ['value 1', 'value 2'], key2: 'value 3'}, 140 | ) 141 | ``` 142 | 143 | ### Locking 144 | 145 | If you're doing more than one transfer in a single financial transaction, or 146 | you're doing other database operations along with the transfer, you'll need to 147 | manually lock the accounts you're using: 148 | 149 | ```ruby 150 | DoubleEntry.lock_accounts(account_a, account_b) do 151 | # Perhaps transfer some money 152 | DoubleEntry.transfer(Money.new(20_00), from: account_a, to: account_b, code: :purchase) 153 | # Perform other tasks that should be commited atomically with the transfer of funds... 154 | end 155 | ``` 156 | 157 | The lock_accounts call generates a database transaction, which must be the 158 | outermost transaction. 159 | 160 | See [DoubleEntry::Locking](lib/double_entry/locking.rb) for more info. 161 | 162 | ### Account Checker/Fixer 163 | 164 | DoubleEntry tries really hard to make sure that stored account balances reflect the running balances from the `double_entry_lines` table, but there is always the unlikely possibility that something will go wrong and the calculated balance might get out of sync with the actual running balance of the lines. 165 | 166 | DoubleEntry therefore provides a couple of tools to give you some confidence that things are working as expected. 167 | 168 | The `DoubleEntry::Validation::LineCheck` will check the `double_entry_lines` table to make sure that the `balance` column correctly reflects the calculated running balance, and that the `double_entry_account_balances` table has the correct value in the `balance` column. If either one of these turn out to be incorrect then it will write an entry into the `double_entry_line_checks` table reporting on the differences. 169 | 170 | You can alternatively pass a `fixer` to the `DoubleEntry::Validation::LineCheck.perform` method which will try and correct the balances. This gem provides the `DoubleEntry::Validation::AccountFixer` class which will correct the balance if it's out of sync. 171 | 172 | Using these classes is optional and both are provided for additional safety checks. If you want to make use of them then it's recommended to run them in a scheduled job, somewhere on the order of hourly to daily, depending on transaction volume. Keep in mind that this process locks accounts as it inspects their balances, so it will prevent new transactions from being written for a short time. 173 | 174 | Here are examples that could go in your scheduled job, depending on your needs: 175 | 176 | ```ruby 177 | # Check all accounts & write the results to the double_entry_line_checks table 178 | DoubleEntry::Validation::LineCheck.perform! 179 | 180 | # Check & fix accounts (results will also be written to the table) 181 | DoubleEntry::Validation::LineCheck.perform!(fixer: DoubleEntry::Validation::AccountFixer.new) 182 | ``` 183 | 184 | See [DoubleEntry::Validation](lib/double_entry/validation) for more info. 185 | 186 | ## Implementation 187 | 188 | All transfers and balances are stored in the lines table. As this is a 189 | double-entry accounting system, each transfer generates two lines table 190 | entries: one for the source account, and one for the destination. 191 | 192 | Lines table entries also store the running balance for the account. To retrieve 193 | the current balance for an account, we find the most recent lines table entry 194 | for it. 195 | 196 | See [DoubleEntry::Line](lib/double_entry/line.rb) for more info. 197 | 198 | AccountBalance records cache the current balance for each Account, and are used 199 | to perform database level locking. 200 | 201 | Transfer metadata is stored in a json(b) column on both the source and destination lines of the transfer. 202 | 203 | ## Configuration 204 | 205 | A configuration file should be used to define a set of accounts, optional scopes on 206 | the accounts, and permitted transfers between those accounts. 207 | 208 | The configuration file should be kept in your application's load path. For example, 209 | *config/initializers/double_entry.rb*. By default, this file will be created when you run the installer, but you will need to fill out your accounts. 210 | 211 | For example, the following specifies two accounts, savings and checking. 212 | Each account is scoped by User (where User is an object with an ID), meaning 213 | each user can have their own account of each type. 214 | 215 | This configuration also specifies that money can be transferred between the two accounts. 216 | 217 | ```ruby 218 | require 'double_entry' 219 | 220 | DoubleEntry.configure do |config| 221 | # Use json(b) column in double_entry_lines table to store metadata instead of separate metadata table 222 | config.json_metadata = true 223 | 224 | config.define_accounts do |accounts| 225 | user_scope = ->(user) do 226 | raise 'not a User' unless user.class.name == 'User' 227 | user.id 228 | end 229 | accounts.define(identifier: :savings, scope_identifier: user_scope, positive_only: true) 230 | accounts.define(identifier: :checking, scope_identifier: user_scope) 231 | end 232 | 233 | config.define_transfers do |transfers| 234 | transfers.define(from: :checking, to: :savings, code: :deposit) 235 | transfers.define(from: :savings, to: :checking, code: :withdraw) 236 | end 237 | end 238 | ``` 239 | 240 | By default an account's currency is the same as Money.default_currency from the money gem. 241 | 242 | You can also specify a currency on a per account basis. 243 | Transfers between accounts of different currencies are not allowed. 244 | 245 | ```ruby 246 | DoubleEntry.configure do |config| 247 | config.define_accounts do |accounts| 248 | accounts.define(identifier: :savings, scope_identifier: user_scope, currency: 'AUD') 249 | end 250 | end 251 | ``` 252 | 253 | ## Testing with RSpec 254 | 255 | Transfering money needs to be run as a top level transaction. This conflicts with RSpec's default behavior of creating a new transaction for every test, causing an exception of type `DoubleEntry::Locking::LockMustBeOutermostTransaction` to be raised. This behavior may be disabled by adding the following lines into your `rails_helper.rb`. 256 | 257 | ```ruby 258 | RSpec.configure do |config| 259 | # ... 260 | # This first line should already be there. You will need to add the second one 261 | config.use_transactional_fixtures = true 262 | DoubleEntry::Locking.configuration.running_inside_transactional_fixtures = true 263 | # ... 264 | end 265 | ``` 266 | 267 | ## Jackhammer 268 | 269 | Run a concurrency test on the code. 270 | 271 | This spawns a bunch of processes, and does random transactions between a set 272 | of accounts, then validates that all the numbers add up at the end. 273 | 274 | You can also tell it to flush out the account balances table at regular 275 | intervals, to validate that new account balances records get created with the 276 | correct balances from the lines table. 277 | 278 | ./script/jack_hammer -t 20 279 | Cleaning out the database... 280 | Setting up 5 accounts... 281 | Spawning 20 processes... 282 | Flushing balances 283 | Process 1 running 1 transfers... 284 | Process 0 running 1 transfers... 285 | Process 3 running 1 transfers... 286 | Process 2 running 1 transfers... 287 | Process 4 running 1 transfers... 288 | Process 5 running 1 transfers... 289 | Process 6 running 1 transfers... 290 | Process 7 running 1 transfers... 291 | Process 8 running 1 transfers... 292 | Process 9 running 1 transfers... 293 | Process 10 running 1 transfers... 294 | Process 11 running 1 transfers... 295 | Process 12 running 1 transfers... 296 | Process 13 running 1 transfers... 297 | Process 14 running 1 transfers... 298 | Process 16 running 1 transfers... 299 | Process 15 running 1 transfers... 300 | Process 17 running 1 transfers... 301 | Process 19 running 1 transfers... 302 | Process 18 running 1 transfers... 303 | Reconciling... 304 | All the Line records were written, FTW! 305 | All accounts reconciled, FTW! 306 | Done successfully :) 307 | 308 | ## Future Direction 309 | 310 | See the Github project [issues](https://github.com/envato/double_entry/issues). 311 | 312 | ## Development Environment Setup 313 | 314 | We're using Docker to provide a convenient and consistent environment for 315 | executing tests during development. This allows engineers to quickly set up 316 | a productive development environment. 317 | 318 | Note: Most development files are mounted in the Docker container. This 319 | enables engineers to edit files in their favourite editor (on the host 320 | OS) and have the changes immediately available in the Docker container 321 | to be exercised. 322 | 323 | One exception to this is the RSpec configuration. Changes to these files will 324 | require a rebuild of the Docker image (step 2). 325 | 326 | Prerequisites: 327 | 328 | * Docker 329 | * Docker Compose 330 | * Git 331 | 332 | 1. Clone this repo. 333 | 334 | ```sh 335 | git clone git@github.com:envato/double_entry.git && cd double_entry 336 | ``` 337 | 338 | 2. Build the Docker image we'll use to run tests 339 | 340 | ```sh 341 | docker-compose build --pull double_entry 342 | ``` 343 | 344 | 3. Startup a container and attach a terminal. This will also start up a 345 | MySQL and Postgres database. 346 | 347 | ```sh 348 | docker-compose run --rm double_entry ash 349 | ``` 350 | 351 | 4. Run the tests 352 | 353 | ```sh 354 | DB=mysql bundle exec rspec 355 | DB=postgres bundle exec rspec 356 | DB=sqlite bundle exec rspec 357 | ``` 358 | 359 | 5. When finished, exit the container terminal and shut down the databases. 360 | 361 | ```sh 362 | exit 363 | docker-compose down 364 | ``` 365 | 366 | ## Contributors 367 | 368 | Many thanks to those who have contributed to both this gem, and the library upon which it was based, over the years: 369 | * Anthony Sellitti - @asellitt 370 | * Clinton Forbes - @clinton 371 | * Eaden McKee - @eadz 372 | * Giancarlo Salamanca - @salamagd 373 | * Jiexin Huang - @jiexinhuang 374 | * Keith Pitt - @keithpitt 375 | * Kelsey Hannan - @KelseyDH 376 | * Mark Turnley - @rabidcarrot 377 | * Martin Jagusch - @MJIO 378 | * Martin Spickermann - @spickermann 379 | * Mary-Anne Cosgrove - @macosgrove 380 | * Orien Madgwick - @orien 381 | * Pete Yandall - @notahat 382 | * Rizal Muthi - @rizalmuthi 383 | * Ryan Allen - @ryan-allen 384 | * Samuel Cochran - @sj26 385 | * Stefan Wrobel - @swrobel 386 | * Stephanie Staub - @stephnacios 387 | * Trung Lê - @joneslee85 388 | * Vahid Ta'eed - @vahid 389 | -------------------------------------------------------------------------------- /spec/double_entry_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | RSpec.describe DoubleEntry do 3 | # these specs blat the DoubleEntry configuration, so take 4 | # a copy and clean up after ourselves 5 | before do 6 | @config_accounts = DoubleEntry.config.accounts 7 | @config_transfers = DoubleEntry.config.transfers 8 | DoubleEntry.config.accounts = DoubleEntry::Account::Set.new 9 | DoubleEntry.config.transfers = DoubleEntry::Transfer::Set.new 10 | end 11 | 12 | after do 13 | DoubleEntry.config.accounts = @config_accounts 14 | DoubleEntry.config.transfers = @config_transfers 15 | end 16 | 17 | describe 'configuration' do 18 | it 'checks for duplicates of accounts' do 19 | expect do 20 | DoubleEntry.configure do |config| 21 | config.define_accounts do |accounts| 22 | accounts.define(identifier: :gah!) 23 | accounts.define(identifier: :gah!) 24 | end 25 | end 26 | end.to raise_error DoubleEntry::DuplicateAccount 27 | end 28 | 29 | it 'checks for duplicates of transfers' do 30 | expect do 31 | DoubleEntry.configure do |config| 32 | config.define_transfers do |transfers| 33 | transfers.define(from: :savings, to: :cash, code: :xfer) 34 | transfers.define(from: :savings, to: :cash, code: :xfer) 35 | end 36 | end 37 | end.to raise_error DoubleEntry::DuplicateTransfer 38 | end 39 | end 40 | 41 | describe 'accounts' do 42 | before do 43 | DoubleEntry.configure do |config| 44 | config.define_accounts do |accounts| 45 | accounts.define(identifier: :unscoped) 46 | accounts.define(identifier: :scoped, scope_identifier: ->(u) { u.id }) 47 | end 48 | end 49 | end 50 | 51 | let(:scope) { double('a scope', id: 1) } 52 | 53 | describe 'fetching' do 54 | it 'can find an unscoped account by identifier' do 55 | expect(DoubleEntry.account(:unscoped)).to_not be_nil 56 | end 57 | 58 | it 'can find a scoped account by identifier' do 59 | expect(DoubleEntry.account(:scoped, scope: scope)).to_not be_nil 60 | end 61 | 62 | it 'raises an exception when it cannot find an account' do 63 | expect { DoubleEntry.account(:invalid) }.to raise_error(DoubleEntry::UnknownAccount) 64 | end 65 | 66 | it 'raises exception when you ask for an unscoped account w/ scope' do 67 | expect { DoubleEntry.account(:unscoped, scope: scope) }.to raise_error(DoubleEntry::UnknownAccount) 68 | end 69 | 70 | it 'raises exception when you ask for a scoped account w/ out scope' do 71 | expect { DoubleEntry.account(:scoped) }.to raise_error(DoubleEntry::UnknownAccount) 72 | end 73 | end 74 | 75 | context 'an unscoped account' do 76 | subject(:unscoped) { DoubleEntry.account(:unscoped) } 77 | 78 | it 'has an identifier' do 79 | expect(unscoped.identifier).to eq :unscoped 80 | end 81 | end 82 | context 'a scoped account' do 83 | subject(:scoped) { DoubleEntry.account(:scoped, scope: scope) } 84 | 85 | it 'has an identifier' do 86 | expect(scoped.identifier).to eq :scoped 87 | end 88 | end 89 | end 90 | 91 | describe 'transfers' do 92 | before do 93 | DoubleEntry.configure do |config| 94 | config.define_accounts do |accounts| 95 | accounts.define(identifier: :savings) 96 | accounts.define(identifier: :cash) 97 | accounts.define(identifier: :trash) 98 | accounts.define(identifier: :bitbucket, currency: :btc) 99 | end 100 | 101 | config.define_transfers do |transfers| 102 | transfers.define(from: :savings, to: :cash, code: :xfer) 103 | transfers.define(from: :trash, to: :bitbucket, code: :mismatch_xfer) 104 | end 105 | end 106 | end 107 | 108 | let(:savings) { DoubleEntry.account(:savings) } 109 | let(:cash) { DoubleEntry.account(:cash) } 110 | let(:trash) { DoubleEntry.account(:trash) } 111 | let(:bitbucket) { DoubleEntry.account(:bitbucket) } 112 | 113 | it 'can transfer from an account to an account, if the transfer is allowed' do 114 | DoubleEntry.transfer( 115 | Money.new(100_00), 116 | from: savings, 117 | to: cash, 118 | code: :xfer, 119 | ) 120 | end 121 | 122 | it 'raises an exception when the transfer is not allowed (wrong direction)' do 123 | expect do 124 | DoubleEntry.transfer( 125 | Money.new(100_00), 126 | from: cash, 127 | to: savings, 128 | code: :xfer, 129 | ) 130 | end.to raise_error DoubleEntry::TransferNotAllowed 131 | end 132 | 133 | it 'raises an exception when the transfer is not allowed (wrong code)' do 134 | expect do 135 | DoubleEntry.transfer( 136 | Money.new(100_00), 137 | from: savings, 138 | to: cash, 139 | code: :yfer, 140 | ) 141 | end.to raise_error DoubleEntry::TransferNotAllowed 142 | end 143 | 144 | it 'raises an exception when the transfer is not allowed (does not exist, at all)' do 145 | expect do 146 | DoubleEntry.transfer( 147 | Money.new(100_00), 148 | from: cash, 149 | to: trash, 150 | ) 151 | end.to raise_error DoubleEntry::TransferNotAllowed 152 | end 153 | 154 | it 'raises an exception when the transfer is not allowed (mismatched currencies)' do 155 | expect do 156 | DoubleEntry.transfer( 157 | Money.new(100_00), 158 | from: trash, 159 | to: bitbucket, 160 | code: :mismatch_xfer, 161 | ) 162 | end.to raise_error DoubleEntry::MismatchedCurrencies 163 | end 164 | end 165 | 166 | describe 'lines' do 167 | before do 168 | DoubleEntry.configure do |config| 169 | config.define_accounts do |accounts| 170 | accounts.define(identifier: :a) 171 | accounts.define(identifier: :b) 172 | end 173 | 174 | config.define_transfers do |transfers| 175 | transfers.define(code: :xfer, from: :a, to: :b) 176 | end 177 | end 178 | 179 | DoubleEntry.transfer(Money.new(10_00), from: account_a, to: account_b, code: :xfer) 180 | end 181 | 182 | let(:account_a) { DoubleEntry.account(:a) } 183 | let(:account_b) { DoubleEntry.account(:b) } 184 | let(:credit_line) { lines_for_account(account_a).first } 185 | let(:debit_line) { lines_for_account(account_b).first } 186 | 187 | it 'has an amount' do 188 | expect(credit_line.amount).to eq(Money.new(-10_00)) 189 | expect(debit_line.amount).to eq(Money.new(10_00)) 190 | end 191 | 192 | it 'has a code' do 193 | expect(credit_line.code).to eq(:xfer) 194 | expect(debit_line.code).to eq(:xfer) 195 | end 196 | 197 | it 'auto-sets scope when assigning account (and partner_accout, is this implementation?)' do 198 | expect(credit_line[:account]).to eq('a') 199 | expect(credit_line[:scope]).to be_nil 200 | expect(credit_line[:partner_account]).to eq('b') 201 | expect(credit_line[:partner_scope]).to be_nil 202 | end 203 | 204 | it 'has a partner_account (or is this implementation?)' do 205 | expect(credit_line.partner_account).to eq debit_line.account 206 | end 207 | 208 | it 'knows if it is an increase or decrease' do 209 | expect(credit_line).to be_decrease 210 | expect(debit_line).to be_increase 211 | expect(credit_line).to_not be_increase 212 | expect(debit_line).to_not be_decrease 213 | end 214 | 215 | it 'can reference its partner' do 216 | expect(credit_line.partner).to eq(debit_line) 217 | expect(debit_line.partner).to eq(credit_line) 218 | end 219 | 220 | it 'can ask for its pair (credit always coming first)' do 221 | expect(credit_line.pair).to eq([credit_line, debit_line]) 222 | expect(debit_line.pair).to eq([credit_line, debit_line]) 223 | end 224 | 225 | it 'can ask for the account (and get an instance)' do 226 | expect(credit_line.account).to eq(account_a) 227 | expect(debit_line.account).to eq(account_b) 228 | end 229 | end 230 | 231 | describe 'balances' do 232 | let(:work) { DoubleEntry.account(:work) } 233 | let(:savings) { DoubleEntry.account(:savings) } 234 | let(:cash) { DoubleEntry.account(:cash) } 235 | let(:store) { DoubleEntry.account(:store) } 236 | let(:btc_store) { DoubleEntry.account(:btc_store) } 237 | let(:btc_wallet) { DoubleEntry.account(:btc_wallet) } 238 | 239 | before do 240 | DoubleEntry.configure do |config| 241 | config.define_accounts do |accounts| 242 | accounts.define(identifier: :work) 243 | accounts.define(identifier: :cash) 244 | accounts.define(identifier: :savings) 245 | accounts.define(identifier: :store) 246 | accounts.define(identifier: :btc_store, currency: 'BTC') 247 | accounts.define(identifier: :btc_wallet, currency: 'BTC') 248 | end 249 | 250 | config.define_transfers do |transfers| 251 | transfers.define(code: :salary, from: :work, to: :cash) 252 | transfers.define(code: :xfer, from: :cash, to: :savings) 253 | transfers.define(code: :xfer, from: :savings, to: :cash) 254 | transfers.define(code: :purchase, from: :cash, to: :store) 255 | transfers.define(code: :layby, from: :cash, to: :store) 256 | transfers.define(code: :deposit, from: :cash, to: :store) 257 | transfers.define(code: :btc_ex, from: :btc_store, to: :btc_wallet) 258 | end 259 | end 260 | 261 | Timecop.freeze 3.weeks.ago + 1.day do 262 | # got paid from work 263 | DoubleEntry.transfer(Money.new(1_000_00), from: work, code: :salary, to: cash) 264 | # transfer half salary into savings 265 | DoubleEntry.transfer(Money.new(500_00), from: cash, code: :xfer, to: savings) 266 | end 267 | 268 | Timecop.freeze 2.weeks.ago + 1.day do 269 | # got myself a darth vader helmet 270 | DoubleEntry.transfer(Money.new(200_00), from: cash, code: :purchase, to: store) 271 | # paid off some of my darth vader suit layby (to go with the helmet) 272 | DoubleEntry.transfer(Money.new(100_00), from: cash, code: :layby, to: store) 273 | # put a deposit on the darth vader voice changer module (for the helmet) 274 | DoubleEntry.transfer(Money.new(100_00), from: cash, code: :deposit, to: store) 275 | end 276 | 277 | Timecop.freeze 1.week.ago + 1.day do 278 | # transfer 200 out of savings 279 | DoubleEntry.transfer(Money.new(200_00), from: savings, code: :xfer, to: cash) 280 | # pay the remaining balance on the darth vader voice changer module 281 | DoubleEntry.transfer(Money.new(200_00), from: cash, code: :purchase, to: store) 282 | end 283 | 284 | Timecop.freeze 1.week.from_now do 285 | # it's the future, man 286 | DoubleEntry.transfer(Money.new(200_00, 'BTC'), from: btc_store, code: :btc_ex, to: btc_wallet) 287 | end 288 | end 289 | 290 | it 'has the initial balances that we expect' do 291 | expect(work.balance).to eq(Money.new(-1_000_00)) 292 | expect(cash.balance).to eq(Money.new(100_00)) 293 | expect(savings.balance).to eq(Money.new(300_00)) 294 | expect(store.balance).to eq(Money.new(600_00)) 295 | expect(btc_wallet.balance).to eq(Money.new(200_00, 'BTC')) 296 | end 297 | 298 | it 'should have correct account balance records' do 299 | [work, cash, savings, store, btc_wallet].each do |account| 300 | expect(DoubleEntry::AccountBalance.find_by_account(account).balance).to eq(account.balance) 301 | end 302 | end 303 | 304 | it 'should have correct account balance currencies' do 305 | expect(DoubleEntry::AccountBalance.find_by_account(btc_wallet).balance.currency).to eq('BTC') 306 | end 307 | 308 | it 'affects origin/destination balance after transfer' do 309 | savings_balance = savings.balance 310 | cash_balance = cash.balance 311 | amount = Money.new(10_00) 312 | 313 | DoubleEntry.transfer(amount, from: savings, code: :xfer, to: cash) 314 | 315 | expect(savings.balance).to eq(savings_balance - amount) 316 | expect(cash.balance).to eq(cash_balance + amount) 317 | end 318 | 319 | it 'can be queried at a given point in time' do 320 | expect(cash.balance(at: 1.week.ago)).to eq(Money.new(100_00)) 321 | end 322 | 323 | it 'can be queries between two points in time' do 324 | expect(cash.balance(from: 3.weeks.ago, to: 2.weeks.ago)).to eq(Money.new(500_00)) 325 | end 326 | 327 | it 'can be queried between two points in time, even in the future' do 328 | expect(btc_wallet.balance(from: Time.now, to: 2.weeks.from_now)).to eq(Money.new(200_00, 'BTC')) 329 | end 330 | 331 | it 'can report on balances, scoped by code' do 332 | expect(cash.balance(code: :salary)).to eq Money.new(1_000_00) 333 | end 334 | 335 | it 'can report on balances, scoped by many codes' do 336 | expect(store.balance(codes: [:layby, :deposit])).to eq(Money.new(200_00)) 337 | end 338 | 339 | it 'has running balances for each line' do 340 | lines = lines_for_account(cash) 341 | expect(lines[0].balance).to eq(Money.new(1_000_00)) # salary 342 | expect(lines[1].balance).to eq(Money.new(500_00)) # savings 343 | expect(lines[2].balance).to eq(Money.new(300_00)) # purchase 344 | expect(lines[3].balance).to eq(Money.new(200_00)) # layby 345 | expect(lines[4].balance).to eq(Money.new(100_00)) # deposit 346 | expect(lines[5].balance).to eq(Money.new(300_00)) # savings 347 | expect(lines[6].balance).to eq(Money.new(100_00)) # purchase 348 | end 349 | end 350 | 351 | describe 'scoping of accounts' do 352 | before do 353 | DoubleEntry.configure do |config| 354 | config.define_accounts do |accounts| 355 | user_scope = ->(user) do 356 | raise 'not a User' unless user.class.name == 'User' 357 | user.id 358 | end 359 | accounts.define(identifier: :bank) 360 | accounts.define(identifier: :cash, scope_identifier: user_scope) 361 | accounts.define(identifier: :savings, scope_identifier: user_scope) 362 | end 363 | 364 | config.define_transfers do |transfers| 365 | transfers.define(from: :bank, to: :cash, code: :xfer) 366 | transfers.define(from: :cash, to: :cash, code: :xfer) 367 | transfers.define(from: :cash, to: :savings, code: :xfer) 368 | end 369 | end 370 | end 371 | 372 | let(:bank) { DoubleEntry.account(:bank) } 373 | let(:cash) { DoubleEntry.account(:cash) } 374 | let(:savings) { DoubleEntry.account(:savings) } 375 | 376 | let(:john) { create(:user) } 377 | let(:johns_cash) { DoubleEntry.account(:cash, scope: john) } 378 | let(:johns_savings) { DoubleEntry.account(:savings, scope: john) } 379 | 380 | let(:ryan) { create(:user) } 381 | let(:ryans_cash) { DoubleEntry.account(:cash, scope: ryan) } 382 | let(:ryans_savings) { DoubleEntry.account(:savings, scope: ryan) } 383 | 384 | it 'treats each separately scoped account having their own separate balances' do 385 | DoubleEntry.transfer(Money.new(20_00), from: bank, to: johns_cash, code: :xfer) 386 | DoubleEntry.transfer(Money.new(10_00), from: bank, to: ryans_cash, code: :xfer) 387 | expect(johns_cash.balance).to eq(Money.new(20_00)) 388 | expect(ryans_cash.balance).to eq(Money.new(10_00)) 389 | end 390 | 391 | it 'allows transfer between two separately scoped accounts' do 392 | DoubleEntry.transfer(Money.new(10_00), from: ryans_cash, to: johns_cash, code: :xfer) 393 | expect(ryans_cash.balance).to eq(Money.new(-10_00)) 394 | expect(johns_cash.balance).to eq(Money.new(10_00)) 395 | end 396 | 397 | it 'reports balance correctly if called from either account or finances object' do 398 | DoubleEntry.transfer(Money.new(10_00), from: ryans_cash, to: johns_cash, code: :xfer) 399 | expect(ryans_cash.balance).to eq(Money.new(-10_00)) 400 | expect(DoubleEntry.balance(:cash, scope: ryan)).to eq(Money.new(-10_00)) 401 | end 402 | 403 | it 'raises an exception if you try to scope with an object instance of differing class to that defined on the account' do 404 | not_a_user = double(id: 7) 405 | 406 | expect do 407 | DoubleEntry.account(:savings, scope: not_a_user) 408 | end.to raise_error RuntimeError, 'not a User' 409 | 410 | expect do 411 | DoubleEntry.balance(:savings, scope: not_a_user) 412 | end.to raise_error RuntimeError, 'not a User' 413 | end 414 | 415 | it 'raises exception if you try to transfer between the same account, despite it being scoped' do 416 | expect do 417 | DoubleEntry.transfer(Money.new(10_00), from: ryans_cash, to: ryans_cash, code: :xfer) 418 | end.to raise_error(DoubleEntry::TransferNotAllowed) 419 | end 420 | 421 | it 'allows transfer from one persons account to the same persons other kind of account' do 422 | DoubleEntry.transfer(Money.new(100_00), from: ryans_cash, to: ryans_savings, code: :xfer) 423 | expect(ryans_cash.balance).to eq(Money.new(-100_00)) 424 | expect(ryans_savings.balance).to eq(Money.new(100_00)) 425 | end 426 | 427 | it 'disallows you to report on scoped accounts globally' do 428 | expect { DoubleEntry.balance(:cash) }.to raise_error DoubleEntry::UnknownAccount 429 | end 430 | end 431 | end 432 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | [Unreleased]: https://github.com/envato/double_entry/compare/v2.0.1...HEAD 11 | 12 | ## [2.0.1] - 2023-11-01 13 | 14 | ### Fixed 15 | 16 | - Resolve Rails 7.1 coder deprecation warning ([#219]). 17 | 18 | [2.0.1]: https://github.com/envato/double_entry/compare/v2.0.0...v2.0.1 19 | [#219]: https://github.com/envato/double_entry/pull/219 20 | 21 | ## [2.0.0] - 2023-10-25 22 | 23 | ### Fixed 24 | 25 | - Ensure LineCheck and AccountFixer can work correctly with unscoped accounts ([#207]). 26 | - Fixes for running on Ruby 3 ([#212]). 27 | 28 | ### Changed 29 | 30 | - Return `[credit, debit]` from `DoubleEntry.transfer` ([#190]). 31 | - Run the test suite against Rails 6.1, 7.0, 7.1, and Ruby 3.1, 3.2 ([#203], [#214], [#217]). 32 | - Migrate CI to run on GitHub Actions ([#205]) 33 | 34 | ### Removed 35 | 36 | - Removed support for Rails < 6.1, and Ruby < 3.0 ([#215], [#217]). 37 | 38 | ### Added 39 | 40 | - Add `credit` and `debit` scopes to the `Line` model ([#192]). 41 | 42 | [2.0.0]: https://github.com/envato/double_entry/compare/v2.0.0.beta5...v2.0.0 43 | [#190]: https://github.com/envato/double_entry/pull/190 44 | [#192]: https://github.com/envato/double_entry/pull/192 45 | [#203]: https://github.com/envato/double_entry/pull/203 46 | [#205]: https://github.com/envato/double_entry/pull/205 47 | [#207]: https://github.com/envato/double_entry/pull/207 48 | [#212]: https://github.com/envato/double_entry/pull/212 49 | [#214]: https://github.com/envato/double_entry/pull/214 50 | [#215]: https://github.com/envato/double_entry/pull/215 51 | [#217]: https://github.com/envato/double_entry/pull/217 52 | 53 | ## [2.0.0.beta5] - 2021-02-24 54 | 55 | ### Changed 56 | 57 | - Use the Ruby 1.9 hash syntax ([#182]). 58 | - Make the Line detail association optional ([#184]). 59 | - Support Ruby 3 ([#196]). 60 | 61 | [#182]: https://github.com/envato/double_entry/pull/182 62 | [#184]: https://github.com/envato/double_entry/pull/184 63 | [#196]: https://github.com/envato/double_entry/pull/196 64 | 65 | ## [2.0.0.beta4] - 2020-01-25 66 | 67 | ### Added 68 | 69 | - Test against Rails 6.0, ([#176]). 70 | 71 | - Support for Ruby 2.7 ([#180]). 72 | 73 | ### Changed 74 | 75 | - Metadata stored by default in a json(b) column for new installs ([#178]). 76 | 77 | - Remove `force: true` from migration ([#181]). 78 | 79 | - Prevent using Ruby 2.2 via restrictions in Gemfile and Gemspec ([#175]). 80 | 81 | [#175]: https://github.com/envato/double_entry/pull/175 82 | [#176]: https://github.com/envato/double_entry/pull/176 83 | [#178]: https://github.com/envato/double_entry/pull/178 84 | [#180]: https://github.com/envato/double_entry/pull/180 85 | [#181]: https://github.com/envato/double_entry/pull/181 86 | 87 | ## [2.0.0.beta3] - 2019-11-14 88 | 89 | ### Fixed 90 | 91 | - Remove duplicate detail columns in `double_entry_lines` table migration, ([#173]). 92 | 93 | [#173]: https://github.com/envato/double_entry/pull/173 94 | 95 | ## [2.0.0.beta2] - 2019-01-27 96 | 97 | ### Removed 98 | 99 | - Extract `DoubleEntry::Reporting` module to a separate gem: 100 | [`double_entry-reporting`](https://github.com/envato/double_entry-reporting). 101 | 102 | If this module is in use in your project add the `double_entry-reporting` gem 103 | and checkout the 104 | [changelog](https://github.com/envato/double_entry-reporting/blob/master/CHANGELOG.md) 105 | for more updates. 106 | 107 | If not in use, one can delete the `double_entry_line_aggregates` table using 108 | the following migration: 109 | 110 | ```ruby 111 | drop_table :double_entry_line_aggregates 112 | ``` 113 | 114 | ## [2.0.0.beta1] - 2018-12-31 115 | 116 | ### Added 117 | 118 | - Added contributor credits to README. 119 | 120 | - Added support for Ruby 2.3, 2.4, 2.5 and 2.6. 121 | 122 | - Added support for Rails 5.0, 5.1 and 5.2 123 | 124 | - Support passing an array of metadata values. 125 | 126 | ```ruby 127 | DoubleEntry.transfer( 128 | Money.new(20_00), 129 | :from => one_account, 130 | :to => another_account, 131 | :code => :a_business_code_for_this_type_of_transfer, 132 | :metadata => { :key1 => ['value 1', 'value 2'], :key2 => 'value 3' }, 133 | ) 134 | ``` 135 | 136 | - Allow partner account to be specified for aggregates. 137 | 138 | - Allow filtering aggregates by multiple metadata key/value pairs. 139 | 140 | - Add index on the `double_entry_line_checks` table. This covers the query to 141 | obtain the last line check. 142 | 143 | Add this index to your database via a migration like: 144 | 145 | ```ruby 146 | def up 147 | add_index "double_entry_line_checks", ["created_at", "last_line_id"], :name => "line_checks_created_at_last_line_id_idx" 148 | end 149 | ``` 150 | 151 | - Log account balance cache errors to the database when performing the line check: 152 | `DoubleEntry::Validation::LineCheck::perform!` 153 | 154 | ### Changed 155 | 156 | - Replaced Machinist with Factory Bot in test suite. 157 | 158 | - Implement `DoubleEntry::Transfer::Set` and `DoubleEntry::Account::Set` with 159 | `Hash`es rather than `Array`s for performance. 160 | 161 | - Reporting API now uses keyword arguments. Note these reporting classes are 162 | marked API private: their interface is not considered stable. 163 | - `DoubleEntry::Reporting::aggregate` 164 | - `DoubleEntry::Reporting::aggregate_array` 165 | - `DoubleEntry::Reporting::Aggregate::new` 166 | - `DoubleEntry::Reporting::Aggregate::formatted_amount` 167 | - `DoubleEntry::Reporting::AggregateArray::new` 168 | - `DoubleEntry::Reporting::LineAggregateFilter::new` 169 | 170 | - Loosened database string column contstraints to the default (255 characters). 171 | Engineering teams can choose to apply this change, or apply their own column 172 | length constraints specific to their needs. ([#152]) 173 | 174 | - Removed default values for the length checks on `code`, `account` and `scope` 175 | ([#152]). These checks will now only be performed when configured with a value: 176 | 177 | ```ruby 178 | DoubleEntry.configure do |config| 179 | config.code_max_length = 47 180 | config.account_identifier_max_length = 31 181 | config.scope_identifier_max_length = 23 182 | end 183 | ``` 184 | - Use `bigint` for monetary values in the database to avoid integer overflow 185 | ([#154]). Apply changes via this migration: 186 | 187 | ```ruby 188 | change_column :double_entry_account_balances, :balance, :bigint, null: false 189 | 190 | change_column :double_entry_line_aggregates, :amount, :bigint, null: false 191 | 192 | change_column :double_entry_lines, :amount, :bigint, null: false 193 | change_column :double_entry_lines, :balance, :bigint, null: false 194 | ``` 195 | - On Rails version 5.1 and above, use `bigint` for foreign key values in the 196 | database to avoid integer overflow ([#154]). Apply changes via this 197 | migration: 198 | 199 | ```ruby 200 | change_column :double_entry_line_checks, :last_line_id, :bigint, null: false 201 | 202 | change_column :double_entry_line_metadata, :line_id, :bigint, null: false 203 | 204 | change_column :double_entry_lines, :partner_id, :bigint, null: true 205 | change_column :double_entry_lines, :detail_id, :bigint, null: true 206 | ``` 207 | 208 | - Line check validation no-longer performs corrections by default. The 209 | `DoubleEntry::Validation::LineCheck::perform!` method will only log validation 210 | failures in the database. To perform auto-correction pass the `fixer` option: 211 | `LineCheck.perform!(fixer: DoubleEntry::Validation::AccountFixer.new)` 212 | 213 | ### Removed 214 | 215 | - Removed support for Ruby 1.9, 2.0, 2.1 and 2.2. 216 | 217 | - Removed support for Rails 3.2, 4.0, and 4.1. 218 | 219 | - Removed unneeded development dependencies from Gemspec. 220 | 221 | - Removed spec and script files from gem package. 222 | 223 | - Removed the `active_record_scope_identifier` method for configuring scoped accounts. 224 | 225 | ```ruby 226 | user_scope = accounts.active_record_scope_identifier(User) 227 | ``` 228 | 229 | As a replacement, please define your own with a lambda: 230 | 231 | ```ruby 232 | user_scope = ->(user) do 233 | raise 'not a User' unless user.class.name == 'User' 234 | user.id 235 | end 236 | ``` 237 | 238 | ### Fixed 239 | 240 | - Fixed more Ruby warnings. 241 | 242 | - Use `double_entry` namespace when publishing to 243 | `ActiveSupport::Notifications`. 244 | 245 | - Fixed problem of Rails version number not being set in migration template for apps using Rails 5 or higher. 246 | 247 | [#152]: https://github.com/envato/double_entry/pull/152 248 | [#154]: https://github.com/envato/double_entry/pull/154 249 | 250 | ## [1.0.1] - 2018-01-06 251 | 252 | ### Removed 253 | 254 | - Removed Rubocop checks and build step. 255 | 256 | ### Fixed 257 | 258 | - Use `Money#positive?` and `Money#negative?` rather than comparing to zero. 259 | Resolves issues when dealing with multiple currencies. 260 | 261 | - Fixed typo in jack_hammer documentation. 262 | 263 | ## [1.0.0] - 2015-08-04 264 | 265 | ### Added 266 | 267 | - Record meta-data against transfers. 268 | 269 | ```ruby 270 | DoubleEntry.transfer( 271 | Money.new(20_00), 272 | :from => one_account, 273 | :to => another_account, 274 | :code => :a_business_code_for_this_type_of_transfer, 275 | :metadata => { :key1 => 'value 1', :key2 => 'value 2' }, 276 | ) 277 | ``` 278 | 279 | This feature requires a new DB table. Please add a migration similar to: 280 | 281 | ```ruby 282 | class CreateDoubleEntryLineMetadata < ActiveRecord::Migration 283 | def self.up 284 | create_table "#{DoubleEntry.table_name_prefix}line_metadata", :force => true do |t| 285 | t.integer "line_id", :null => false 286 | t.string "key", :limit => 48, :null => false 287 | t.string "value", :limit => 64, :null => false 288 | t.timestamps :null => false 289 | end 290 | 291 | add_index "#{DoubleEntry.table_name_prefix}line_metadata", 292 | ["line_id", "key", "value"], 293 | :name => "lines_meta_line_id_key_value_idx" 294 | end 295 | 296 | def self.down 297 | drop_table "#{DoubleEntry.table_name_prefix}line_metadata" 298 | end 299 | end 300 | ``` 301 | 302 | ### Changed 303 | 304 | - Raise `DoubleEntry::Locking::LockWaitTimeout` for lock wait timeouts. 305 | 306 | ### Fixed 307 | 308 | - Ensure that a range is specified when performing an aggregate function over 309 | lines. 310 | 311 | ## [0.10.3] - 2015-07-15 312 | 313 | ### Added 314 | 315 | - Check code format with Rubocop as part of the CI build. 316 | 317 | ### Fixed 318 | 319 | - More Rubocop code formatting issues fixed. 320 | 321 | ## [0.10.2] - 2015-07-10 322 | 323 | ### Fixed 324 | 325 | - `DoubleEntry::Reporting::AggregateArray` correctly retreives previously 326 | calculated aggregates. 327 | 328 | ## [0.10.1] - 2015-07-06 329 | 330 | ### Added 331 | 332 | - Run CI build against Ruby 2.2.0. 333 | 334 | - Added Rubocop and resolved code formatting issues. 335 | 336 | ### Changed 337 | 338 | - Reduced permutations of DB, Ruby and Rails in CI build. 339 | 340 | - Build status badge displayed in README reports on just the master branch. 341 | 342 | - Update RSpec configuration with latest recommended options. 343 | 344 | ### Fixed 345 | 346 | - Addressed Ruby warnings. 347 | 348 | - Fixed circular arg reference. 349 | 350 | ## [0.10.0] - 2015-01-09 351 | 352 | ### Added 353 | 354 | - Define accounts that can be negative only. 355 | 356 | ```ruby 357 | DoubleEntry.configure do |config| 358 | config.define_accounts do |accounts| 359 | accounts.define( 360 | :identifier => :my_account_that_never_goes_positive, 361 | :negative_only => true 362 | ) 363 | end 364 | end 365 | ``` 366 | 367 | - Run CI build against Rails 4.2 368 | 369 | ## [0.9.0] - 2014-12-08 370 | 371 | ### Changed 372 | 373 | - `DoubleEntry::Reporting::Agregate#formated_amount` no longer accepts 374 | `currency` argument. 375 | 376 | ## [0.8.0] - 2014-11-19 377 | 378 | ### Added 379 | 380 | - Log when we encounter deadlocks causing restart/retry. 381 | 382 | ## [0.7.2] - 2014-11-18 383 | 384 | ### Removed 385 | 386 | - Removed `DoubleEntry::currency` method. 387 | 388 | ## [0.7.1] - 2014-11-17 389 | 390 | ### Fixed 391 | 392 | - `DoubleEntry::balance` and `DoubleEntry::account` now raise 393 | `DoubleEntry::AccountScopeMismatchError` if the scope provided is not of 394 | the same type in the account definition. 395 | 396 | - Speed up CI build. 397 | 398 | ## [0.7.0] - 2014-11-12 399 | 400 | ### Added 401 | 402 | - Added support for currency. :money_with_wings: 403 | 404 | ### Changed 405 | 406 | - Require at least version 6.0 of Money gem. 407 | 408 | ## [0.6.1] - 2014-10-10 409 | 410 | ### Changed 411 | 412 | - Removed use of Active Record callbacks in `DoubleEntry::Line`. 413 | 414 | - Changed `DoubleEntry::Reporting::WeekRange` calculation to use 415 | `Date#cweek`. 416 | 417 | ## [0.6.0] - 2014-08-23 418 | 419 | ### Fixed 420 | 421 | - Fixed defect preventing locking a scoped and a non scoped account. 422 | 423 | ## [0.5.0] - 2014-08-01 424 | 425 | ### Added 426 | 427 | - Added a convenience method for defining active record scope identifiers. 428 | 429 | ```ruby 430 | DoubleEntry.configure do |config| 431 | config.define_accounts do |accounts| 432 | user_scope = accounts.active_record_scope_identifier(User) 433 | accounts.define(:identifier => :checking, :scope_identifier => user_scope) 434 | end 435 | end 436 | ``` 437 | 438 | - Added support for SQLite. 439 | 440 | ### Removed 441 | 442 | - Removed errors: `DoubleEntry::RequiredMetaMissing` and 443 | `DoubleEntry::UserAccountNotLocked`. 444 | 445 | ### Fixed 446 | 447 | - Fixed `Reporting::reconciled?` support for account scopes. 448 | 449 | ## [0.4.0] - 2014-07-17 450 | 451 | ### Added 452 | 453 | - Added Yardoc documention to the `DoubleEntry::balance` method. 454 | 455 | ### Changed 456 | 457 | - Changed `Line#debit?` to `Line#increase?` and `Line#credit?` to 458 | `Line#decrease?`. 459 | 460 | ### Removed 461 | 462 | - Removed the `DoubleEntry::Line#meta` attribute. 463 | 464 | ## [0.3.1] - 2014-07-11 465 | 466 | ### Fixed 467 | 468 | - Obtain a year range array without prioviding a start date. 469 | 470 | ## [0.3.0] - 2014-07-11 471 | 472 | ### Added 473 | 474 | - Add Yardoc to `Reporting` module. 475 | - Allow reporting month and year time ranges without a start date. 476 | 477 | ### Changed 478 | 479 | - Use ruby18 hash syntax for configuration example in README. 480 | 481 | ### Removed 482 | 483 | - Removed `DoubleEntry::describe` and `DoubleEntry::Line#description` 484 | methods. 485 | 486 | ## [0.2.0] - 2014-06-28 487 | 488 | ### Added 489 | 490 | - Added a configuration class to define valid accounts and transfers. 491 | 492 | ```ruby 493 | DoubleEntry.configure do |config| 494 | config.define_accounts do |accounts| 495 | accounts.define(identifier: :savings, positive_only: true) 496 | accounts.define(identifier: :checking) 497 | end 498 | 499 | config.define_transfers do |transfers| 500 | transfers.define(from: :checking, to: :savings, code: :deposit) 501 | transfers.define(from: :savings, to: :checking, code: :withdraw) 502 | end 503 | end 504 | ``` 505 | 506 | ### Changed 507 | 508 | - Move reporting classes into the `DoubleEntry::Reporting` namespace. Mark 509 | this module as `@api private`: internal use only. 510 | 511 | ## 0.1.0 - 2014-06-20 512 | 513 | ### Added 514 | 515 | - Library released as Open Source! 516 | 517 | [2.0.0.beta5]: https://github.com/envato/double_entry/compare/v2.0.0.beta4...v2.0.0.beta5 518 | [2.0.0.beta4]: https://github.com/envato/double_entry/compare/v2.0.0.beta3...v2.0.0.beta4 519 | [2.0.0.beta3]: https://github.com/envato/double_entry/compare/v2.0.0.beta2...v2.0.0.beta3 520 | [2.0.0.beta2]: https://github.com/envato/double_entry/compare/v2.0.0.beta1...v2.0.0.beta2 521 | [2.0.0.beta1]: https://github.com/envato/double_entry/compare/v1.0.1...v2.0.0.beta1 522 | [1.0.1]: https://github.com/envato/double_entry/compare/v1.0.0...v1.0.1 523 | [1.0.0]: https://github.com/envato/double_entry/compare/v0.10.3...v1.0.0 524 | [0.10.3]: https://github.com/envato/double_entry/compare/v0.10.2...v0.10.3 525 | [0.10.2]: https://github.com/envato/double_entry/compare/v0.10.1...v0.10.2 526 | [0.10.1]: https://github.com/envato/double_entry/compare/v0.10.0...v0.10.1 527 | [0.10.0]: https://github.com/envato/double_entry/compare/v0.9.0...v0.10.0 528 | [0.9.0]: https://github.com/envato/double_entry/compare/v0.8.0...v0.9.0 529 | [0.8.0]: https://github.com/envato/double_entry/compare/v0.7.2...v0.8.0 530 | [0.7.2]: https://github.com/envato/double_entry/compare/v0.7.1...v0.7.2 531 | [0.7.1]: https://github.com/envato/double_entry/compare/v0.7.0...v0.7.1 532 | [0.7.0]: https://github.com/envato/double_entry/compare/v0.6.1...v0.7.0 533 | [0.6.1]: https://github.com/envato/double_entry/compare/v0.6.0...v0.6.1 534 | [0.6.0]: https://github.com/envato/double_entry/compare/v0.5.0...v0.6.0 535 | [0.5.0]: https://github.com/envato/double_entry/compare/v0.4.0...v0.5.0 536 | [0.4.0]: https://github.com/envato/double_entry/compare/v0.3.1...v0.4.0 537 | [0.3.1]: https://github.com/envato/double_entry/compare/v0.3.0...v0.3.1 538 | [0.3.0]: https://github.com/envato/double_entry/compare/v0.2.0...v0.3.0 539 | [0.2.0]: https://github.com/envato/double_entry/compare/v0.1.0...v0.2.0 540 | --------------------------------------------------------------------------------