double_entry 1.0.1 → 2.0.0.beta5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/CHANGELOG.md +497 -0
- data/README.md +107 -44
- data/double_entry.gemspec +22 -49
- data/lib/active_record/locking_extensions.rb +3 -3
- data/lib/active_record/locking_extensions/log_subscriber.rb +1 -1
- data/lib/double_entry.rb +29 -21
- data/lib/double_entry/account.rb +39 -46
- data/lib/double_entry/account_balance.rb +20 -3
- data/lib/double_entry/balance_calculator.rb +5 -5
- data/lib/double_entry/configurable.rb +1 -0
- data/lib/double_entry/configuration.rb +8 -2
- data/lib/double_entry/errors.rb +13 -13
- data/lib/double_entry/line.rb +7 -6
- data/lib/double_entry/locking.rb +5 -5
- data/lib/double_entry/transfer.rb +37 -30
- data/lib/double_entry/validation.rb +1 -0
- data/lib/double_entry/validation/account_fixer.rb +36 -0
- data/lib/double_entry/validation/line_check.rb +25 -43
- data/lib/double_entry/version.rb +1 -1
- data/lib/generators/double_entry/install/install_generator.rb +22 -1
- data/lib/generators/double_entry/install/templates/initializer.rb +20 -0
- data/lib/generators/double_entry/install/templates/migration.rb +45 -55
- metadata +35 -256
- data/.gitignore +0 -32
- data/.rspec +0 -2
- data/.travis.yml +0 -29
- data/.yardopts +0 -2
- data/Gemfile +0 -2
- data/Rakefile +0 -15
- data/lib/double_entry/reporting.rb +0 -181
- data/lib/double_entry/reporting/aggregate.rb +0 -110
- data/lib/double_entry/reporting/aggregate_array.rb +0 -76
- data/lib/double_entry/reporting/day_range.rb +0 -42
- data/lib/double_entry/reporting/hour_range.rb +0 -45
- data/lib/double_entry/reporting/line_aggregate.rb +0 -16
- data/lib/double_entry/reporting/line_aggregate_filter.rb +0 -79
- data/lib/double_entry/reporting/month_range.rb +0 -94
- data/lib/double_entry/reporting/time_range.rb +0 -59
- data/lib/double_entry/reporting/time_range_array.rb +0 -49
- data/lib/double_entry/reporting/week_range.rb +0 -107
- data/lib/double_entry/reporting/year_range.rb +0 -40
- data/script/jack_hammer +0 -210
- data/script/setup.sh +0 -8
- data/spec/active_record/locking_extensions_spec.rb +0 -110
- data/spec/double_entry/account_balance_spec.rb +0 -7
- data/spec/double_entry/account_spec.rb +0 -130
- data/spec/double_entry/balance_calculator_spec.rb +0 -88
- data/spec/double_entry/configuration_spec.rb +0 -50
- data/spec/double_entry/line_spec.rb +0 -80
- data/spec/double_entry/locking_spec.rb +0 -214
- data/spec/double_entry/performance/double_entry_performance_spec.rb +0 -32
- data/spec/double_entry/performance/reporting/aggregate_performance_spec.rb +0 -50
- data/spec/double_entry/reporting/aggregate_array_spec.rb +0 -123
- data/spec/double_entry/reporting/aggregate_spec.rb +0 -205
- data/spec/double_entry/reporting/line_aggregate_filter_spec.rb +0 -90
- data/spec/double_entry/reporting/line_aggregate_spec.rb +0 -39
- data/spec/double_entry/reporting/month_range_spec.rb +0 -139
- data/spec/double_entry/reporting/time_range_array_spec.rb +0 -169
- data/spec/double_entry/reporting/time_range_spec.rb +0 -45
- data/spec/double_entry/reporting/week_range_spec.rb +0 -103
- data/spec/double_entry/reporting_spec.rb +0 -181
- data/spec/double_entry/transfer_spec.rb +0 -93
- data/spec/double_entry/validation/line_check_spec.rb +0 -99
- data/spec/double_entry_spec.rb +0 -428
- data/spec/generators/double_entry/install/install_generator_spec.rb +0 -30
- data/spec/spec_helper.rb +0 -118
- data/spec/support/accounts.rb +0 -21
- data/spec/support/blueprints.rb +0 -43
- data/spec/support/database.example.yml +0 -21
- data/spec/support/database.travis.yml +0 -24
- data/spec/support/double_entry_spec_helper.rb +0 -27
- data/spec/support/gemfiles/Gemfile.rails-3.2.x +0 -8
- data/spec/support/gemfiles/Gemfile.rails-4.1.x +0 -6
- data/spec/support/gemfiles/Gemfile.rails-4.2.x +0 -5
- data/spec/support/gemfiles/Gemfile.rails-5.0.x +0 -5
- data/spec/support/performance_helper.rb +0 -26
- data/spec/support/reporting_configuration.rb +0 -6
- data/spec/support/schema.rb +0 -74
@@ -1,181 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
RSpec.describe DoubleEntry::Reporting do
|
3
|
-
describe '::configure' do
|
4
|
-
describe 'start_of_business' do
|
5
|
-
subject(:start_of_business) { DoubleEntry::Reporting.configuration.start_of_business }
|
6
|
-
|
7
|
-
context 'configured to 2011-03-12' do
|
8
|
-
before do
|
9
|
-
DoubleEntry::Reporting.configure do |config|
|
10
|
-
config.start_of_business = Time.new(2011, 3, 12)
|
11
|
-
end
|
12
|
-
end
|
13
|
-
|
14
|
-
it { should eq Time.new(2011, 3, 12) }
|
15
|
-
end
|
16
|
-
|
17
|
-
context 'not configured' do
|
18
|
-
it { should eq Time.new(1970, 1, 1) }
|
19
|
-
end
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
describe '::scopes_with_minimum_balance_for_account' do
|
24
|
-
subject(:scopes) { DoubleEntry::Reporting.scopes_with_minimum_balance_for_account(minimum_balance, :checking) }
|
25
|
-
|
26
|
-
context "a 'checking' account with balance $100" do
|
27
|
-
let!(:user) { User.make!(:checking_balance => Money.new(100_00)) }
|
28
|
-
|
29
|
-
context 'when searching for balance $99' do
|
30
|
-
let(:minimum_balance) { Money.new(99_00) }
|
31
|
-
it { should include user.id }
|
32
|
-
end
|
33
|
-
|
34
|
-
context 'when searching for balance $100' do
|
35
|
-
let(:minimum_balance) { Money.new(100_00) }
|
36
|
-
it { should include user.id }
|
37
|
-
end
|
38
|
-
|
39
|
-
context 'when searching for balance $101' do
|
40
|
-
let(:minimum_balance) { Money.new(101_00) }
|
41
|
-
it { should_not include user.id }
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
describe '::aggregate' do
|
47
|
-
before do
|
48
|
-
# get rid of "helpful" predefined config
|
49
|
-
@config_accounts = DoubleEntry.configuration.accounts
|
50
|
-
@config_transfers = DoubleEntry.configuration.transfers
|
51
|
-
DoubleEntry.configuration.accounts = DoubleEntry::Account::Set.new
|
52
|
-
DoubleEntry.configuration.transfers = DoubleEntry::Transfer::Set.new
|
53
|
-
|
54
|
-
DoubleEntry.configure do |config|
|
55
|
-
config.define_accounts do |accounts|
|
56
|
-
accounts.define(:identifier => :savings)
|
57
|
-
accounts.define(:identifier => :cash)
|
58
|
-
accounts.define(:identifier => :credit)
|
59
|
-
end
|
60
|
-
|
61
|
-
config.define_transfers do |transfers|
|
62
|
-
transfers.define(:from => :savings, :to => :cash, :code => :spend)
|
63
|
-
transfers.define(:from => :cash, :to => :savings, :code => :save)
|
64
|
-
transfers.define(:from => :cash, :to => :credit, :code => :bill)
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
cash = DoubleEntry.account(:cash)
|
69
|
-
savings = DoubleEntry.account(:savings)
|
70
|
-
credit = DoubleEntry.account(:credit)
|
71
|
-
DoubleEntry.transfer(Money.new(10_00), :from => cash, :to => savings, :code => :save, :metadata => { :reason => 'payday' })
|
72
|
-
DoubleEntry.transfer(Money.new(10_00), :from => cash, :to => savings, :code => :save, :metadata => { :reason => 'payday' })
|
73
|
-
DoubleEntry.transfer(Money.new(20_00), :from => cash, :to => savings, :code => :save)
|
74
|
-
DoubleEntry.transfer(Money.new(20_00), :from => cash, :to => savings, :code => :save)
|
75
|
-
DoubleEntry.transfer(Money.new(30_00), :from => cash, :to => credit, :code => :bill)
|
76
|
-
DoubleEntry.transfer(Money.new(40_00), :from => cash, :to => credit, :code => :bill)
|
77
|
-
DoubleEntry.transfer(Money.new(50_00), :from => savings, :to => cash, :code => :spend)
|
78
|
-
DoubleEntry.transfer(Money.new(60_00), :from => savings, :to => cash, :code => :spend, :metadata => { :category => 'entertainment' })
|
79
|
-
end
|
80
|
-
|
81
|
-
after do
|
82
|
-
# restore "helpful" predefined config
|
83
|
-
DoubleEntry.configuration.accounts = @config_accounts
|
84
|
-
DoubleEntry.configuration.transfers = @config_transfers
|
85
|
-
end
|
86
|
-
|
87
|
-
describe 'filter solely on transaction identifiers and time' do
|
88
|
-
let(:function) { :sum }
|
89
|
-
let(:account) { :savings }
|
90
|
-
let(:code) { :save }
|
91
|
-
let(:range) { DoubleEntry::Reporting::MonthRange.current }
|
92
|
-
|
93
|
-
subject(:aggregate) do
|
94
|
-
DoubleEntry::Reporting.aggregate(function, account, code, range)
|
95
|
-
end
|
96
|
-
|
97
|
-
specify 'Total attempted to save' do
|
98
|
-
expect(aggregate).to eq(Money.new(60_00))
|
99
|
-
end
|
100
|
-
end
|
101
|
-
|
102
|
-
describe 'filter by named scope that does not take arguments' do
|
103
|
-
let(:function) { :sum }
|
104
|
-
let(:account) { :savings }
|
105
|
-
let(:code) { :save }
|
106
|
-
let(:range) { DoubleEntry::Reporting::MonthRange.current }
|
107
|
-
|
108
|
-
subject(:aggregate) do
|
109
|
-
DoubleEntry::Reporting.aggregate(
|
110
|
-
function, account, code, range,
|
111
|
-
:filter => [
|
112
|
-
:scope => {
|
113
|
-
:name => :ten_dollar_transfers,
|
114
|
-
},
|
115
|
-
]
|
116
|
-
)
|
117
|
-
end
|
118
|
-
|
119
|
-
before do
|
120
|
-
DoubleEntry::Line.class_eval do
|
121
|
-
scope :ten_dollar_transfers, -> { where(:amount => Money.new(10_00).fractional) }
|
122
|
-
end
|
123
|
-
end
|
124
|
-
|
125
|
-
specify 'Total amount of $10 transfers attempted to save' do
|
126
|
-
expect(aggregate).to eq(Money.new(20_00))
|
127
|
-
end
|
128
|
-
end
|
129
|
-
|
130
|
-
describe 'filter by named scope that takes arguments' do
|
131
|
-
let(:function) { :sum }
|
132
|
-
let(:account) { :savings }
|
133
|
-
let(:code) { :save }
|
134
|
-
let(:range) { DoubleEntry::Reporting::MonthRange.current }
|
135
|
-
|
136
|
-
subject(:aggregate) do
|
137
|
-
DoubleEntry::Reporting.aggregate(
|
138
|
-
function, account, code, range,
|
139
|
-
:filter => [
|
140
|
-
:scope => {
|
141
|
-
:name => :specific_transfer_amount,
|
142
|
-
:arguments => [Money.new(20_00)],
|
143
|
-
},
|
144
|
-
]
|
145
|
-
)
|
146
|
-
end
|
147
|
-
|
148
|
-
before do
|
149
|
-
DoubleEntry::Line.class_eval do
|
150
|
-
scope :specific_transfer_amount, ->(amount) { where(:amount => amount.fractional) }
|
151
|
-
end
|
152
|
-
end
|
153
|
-
|
154
|
-
specify 'Total amount of transfers of $20 attempted to save' do
|
155
|
-
expect(aggregate).to eq(Money.new(40_00))
|
156
|
-
end
|
157
|
-
end
|
158
|
-
|
159
|
-
describe 'filter by metadata' do
|
160
|
-
let(:function) { :sum }
|
161
|
-
let(:account) { :savings }
|
162
|
-
let(:code) { :save }
|
163
|
-
let(:range) { DoubleEntry::Reporting::MonthRange.current }
|
164
|
-
|
165
|
-
subject(:aggregate) do
|
166
|
-
DoubleEntry::Reporting.aggregate(
|
167
|
-
function, account, code, range,
|
168
|
-
:filter => [
|
169
|
-
:metadata => {
|
170
|
-
:reason => 'payday',
|
171
|
-
},
|
172
|
-
]
|
173
|
-
)
|
174
|
-
end
|
175
|
-
|
176
|
-
specify 'Total amount of transfers saved because payday' do
|
177
|
-
expect(aggregate).to eq(Money.new(20_00))
|
178
|
-
end
|
179
|
-
end
|
180
|
-
end
|
181
|
-
end
|
@@ -1,93 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
module DoubleEntry
|
3
|
-
RSpec.describe Transfer do
|
4
|
-
describe '::new' do
|
5
|
-
context 'given a code 47 characters in length' do
|
6
|
-
let(:code) { 'xxxxxxxxxxxxxxxx 47 characters xxxxxxxxxxxxxxxx' }
|
7
|
-
specify do
|
8
|
-
expect { Transfer.new(:code => code) }.to_not raise_error
|
9
|
-
end
|
10
|
-
end
|
11
|
-
|
12
|
-
context 'given a code 48 characters in length' do
|
13
|
-
let(:code) { 'xxxxxxxxxxxxxxxx 48 characters xxxxxxxxxxxxxxxxx' }
|
14
|
-
specify do
|
15
|
-
expect { Transfer.new(:code => code) }.to raise_error TransferCodeTooLongError, /'#{code}'/
|
16
|
-
end
|
17
|
-
end
|
18
|
-
end
|
19
|
-
|
20
|
-
describe '::transfer' do
|
21
|
-
let(:amount) { Money.new(10_00) }
|
22
|
-
let(:user) { User.make! }
|
23
|
-
let(:test) { DoubleEntry.account(:test, :scope => user) }
|
24
|
-
let(:savings) { DoubleEntry.account(:savings, :scope => user) }
|
25
|
-
let(:new_lines) { Line.all[-2..-1] }
|
26
|
-
|
27
|
-
subject(:transfer) { Transfer.transfer(amount, options) }
|
28
|
-
|
29
|
-
context 'without metadata' do
|
30
|
-
let(:options) { { :from => test, :to => savings, :code => :bonus } }
|
31
|
-
|
32
|
-
it 'creates lines' do
|
33
|
-
expect { transfer }.to change { Line.count }.by 2
|
34
|
-
end
|
35
|
-
|
36
|
-
it 'does not create metadata lines' do
|
37
|
-
expect { transfer }.not_to change { LineMetadata.count }
|
38
|
-
end
|
39
|
-
end
|
40
|
-
|
41
|
-
context 'with metadata' do
|
42
|
-
let(:options) { { :from => test, :to => savings, :code => :bonus, :metadata => { :country => 'AU', :tax => 'GST' } } }
|
43
|
-
let(:new_metadata) { LineMetadata.all[-4..-1] }
|
44
|
-
|
45
|
-
it 'creates metadata lines' do
|
46
|
-
expect { transfer }.to change { LineMetadata.count }.by 4
|
47
|
-
end
|
48
|
-
|
49
|
-
it 'associates the metadata lines with the transfer lines' do
|
50
|
-
transfer
|
51
|
-
expect(new_metadata.count { |meta| meta.line == new_lines.first }).to be 2
|
52
|
-
expect(new_metadata.count { |meta| meta.line == new_lines.last }).to be 2
|
53
|
-
end
|
54
|
-
|
55
|
-
it 'stores the first key/value pair' do
|
56
|
-
transfer
|
57
|
-
countries = new_metadata.select { |meta| meta.key == :country }
|
58
|
-
expect(countries.size).to be 2
|
59
|
-
expect(countries.count { |meta| meta.value == 'AU' }).to be 2
|
60
|
-
end
|
61
|
-
|
62
|
-
it 'associates the first key/value pair with both lines' do
|
63
|
-
transfer
|
64
|
-
countries = new_metadata.select { |meta| meta.key == :country }
|
65
|
-
expect(countries.map(&:line).uniq.size).to be 2
|
66
|
-
end
|
67
|
-
|
68
|
-
it 'stores another key/value pair' do
|
69
|
-
transfer
|
70
|
-
taxes = new_metadata.select { |meta| meta.key == :tax }
|
71
|
-
expect(taxes.size).to be 2
|
72
|
-
expect(taxes.count { |meta| meta.value == 'GST' }).to be 2
|
73
|
-
end
|
74
|
-
end
|
75
|
-
end
|
76
|
-
|
77
|
-
describe Transfer::Set do
|
78
|
-
describe '#define' do
|
79
|
-
before do
|
80
|
-
subject.define(
|
81
|
-
:code => 'code',
|
82
|
-
:from => double(:identifier => 'from'),
|
83
|
-
:to => double(:identifier => 'to'),
|
84
|
-
)
|
85
|
-
end
|
86
|
-
its(:first) { should be_a Transfer }
|
87
|
-
its('first.code') { should eq 'code' }
|
88
|
-
its('first.from.identifier') { should eq 'from' }
|
89
|
-
its('first.to.identifier') { should eq 'to' }
|
90
|
-
end
|
91
|
-
end
|
92
|
-
end
|
93
|
-
end
|
@@ -1,99 +0,0 @@
|
|
1
|
-
# encoding: utf-8
|
2
|
-
module DoubleEntry
|
3
|
-
module Validation
|
4
|
-
RSpec.describe LineCheck do
|
5
|
-
describe '#last' do
|
6
|
-
context 'Given some checks have been created' do
|
7
|
-
before do
|
8
|
-
Timecop.freeze 3.minutes.ago do
|
9
|
-
LineCheck.create! :last_line_id => 100, :errors_found => false, :log => ''
|
10
|
-
end
|
11
|
-
Timecop.freeze 1.minute.ago do
|
12
|
-
LineCheck.create! :last_line_id => 300, :errors_found => false, :log => ''
|
13
|
-
end
|
14
|
-
Timecop.freeze 2.minutes.ago do
|
15
|
-
LineCheck.create! :last_line_id => 200, :errors_found => false, :log => ''
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
it 'should find the newest LineCheck created (by creation_date)' do
|
20
|
-
expect(LineCheck.last.last_line_id).to eq 300
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
describe '#perform!' do
|
26
|
-
subject(:performed_line_check) { LineCheck.perform! }
|
27
|
-
|
28
|
-
context 'Given a user with 100 dollars' do
|
29
|
-
before { User.make!(:savings_balance => Money.new(100_00)) }
|
30
|
-
|
31
|
-
context 'And all is consistent' do
|
32
|
-
context 'And all lines have been checked' do
|
33
|
-
before { LineCheck.perform! }
|
34
|
-
|
35
|
-
it { should be_nil }
|
36
|
-
|
37
|
-
it 'should not persist a new LineCheck' do
|
38
|
-
expect { LineCheck.perform! }.
|
39
|
-
to_not change { LineCheck.count }
|
40
|
-
end
|
41
|
-
end
|
42
|
-
|
43
|
-
it { should be_instance_of LineCheck }
|
44
|
-
its(:errors_found) { should eq false }
|
45
|
-
|
46
|
-
it 'should persist the LineCheck' do
|
47
|
-
line_check = LineCheck.perform!
|
48
|
-
expect(LineCheck.last).to eq line_check
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
context 'And there is a consistency error in lines' do
|
53
|
-
before { DoubleEntry::Line.order(:id).limit(1).update_all('balance = balance + 1') }
|
54
|
-
|
55
|
-
its(:errors_found) { should be true }
|
56
|
-
its(:log) { should match(/Error on line/) }
|
57
|
-
|
58
|
-
it 'should correct the running balance' do
|
59
|
-
expect { LineCheck.perform! }.
|
60
|
-
to change { DoubleEntry::Line.order(:id).first.balance }.
|
61
|
-
by Money.new(-1)
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
context 'And there is a consistency error in account balance' do
|
66
|
-
before { DoubleEntry::AccountBalance.order(:id).limit(1).update_all('balance = balance + 1') }
|
67
|
-
|
68
|
-
its(:errors_found) { should be true }
|
69
|
-
|
70
|
-
it 'should correct the account balance' do
|
71
|
-
expect { LineCheck.perform! }.
|
72
|
-
to change { DoubleEntry::AccountBalance.order(:id).first.balance }.
|
73
|
-
by Money.new(-1)
|
74
|
-
end
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
context 'Given a user with a non default currency balance' do
|
79
|
-
before { User.make!(:bitcoin_balance => Money.new(100_00, 'BTC')) }
|
80
|
-
its(:errors_found) { should eq false }
|
81
|
-
context 'And there is a consistency error in lines' do
|
82
|
-
before { DoubleEntry::Line.order(:id).limit(1).update_all('balance = balance + 1') }
|
83
|
-
|
84
|
-
its(:errors_found) { should eq true }
|
85
|
-
it 'should correct the running balance' do
|
86
|
-
expect { LineCheck.perform! }.
|
87
|
-
to change { DoubleEntry::Line.order(:id).first.balance }.
|
88
|
-
by Money.new(-1, 'BTC')
|
89
|
-
end
|
90
|
-
end
|
91
|
-
end
|
92
|
-
|
93
|
-
it 'has a table name prefixed with double_entry_' do
|
94
|
-
expect(LineCheck.table_name).to eq 'double_entry_line_checks'
|
95
|
-
end
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
|
-
end
|
data/spec/double_entry_spec.rb
DELETED
@@ -1,428 +0,0 @@
|
|
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.configuration.accounts
|
7
|
-
@config_transfers = DoubleEntry.configuration.transfers
|
8
|
-
DoubleEntry.configuration.accounts = DoubleEntry::Account::Set.new
|
9
|
-
DoubleEntry.configuration.transfers = DoubleEntry::Transfer::Set.new
|
10
|
-
end
|
11
|
-
|
12
|
-
after do
|
13
|
-
DoubleEntry.configuration.accounts = @config_accounts
|
14
|
-
DoubleEntry.configuration.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 = accounts.active_record_scope_identifier(User)
|
356
|
-
accounts.define(:identifier => :bank)
|
357
|
-
accounts.define(:identifier => :cash, :scope_identifier => user_scope)
|
358
|
-
accounts.define(:identifier => :savings, :scope_identifier => user_scope)
|
359
|
-
end
|
360
|
-
|
361
|
-
config.define_transfers do |transfers|
|
362
|
-
transfers.define(:from => :bank, :to => :cash, :code => :xfer)
|
363
|
-
transfers.define(:from => :cash, :to => :cash, :code => :xfer)
|
364
|
-
transfers.define(:from => :cash, :to => :savings, :code => :xfer)
|
365
|
-
end
|
366
|
-
end
|
367
|
-
end
|
368
|
-
|
369
|
-
let(:bank) { DoubleEntry.account(:bank) }
|
370
|
-
let(:cash) { DoubleEntry.account(:cash) }
|
371
|
-
let(:savings) { DoubleEntry.account(:savings) }
|
372
|
-
|
373
|
-
let(:john) { User.make! }
|
374
|
-
let(:johns_cash) { DoubleEntry.account(:cash, :scope => john) }
|
375
|
-
let(:johns_savings) { DoubleEntry.account(:savings, :scope => john) }
|
376
|
-
|
377
|
-
let(:ryan) { User.make! }
|
378
|
-
let(:ryans_cash) { DoubleEntry.account(:cash, :scope => ryan) }
|
379
|
-
let(:ryans_savings) { DoubleEntry.account(:savings, :scope => ryan) }
|
380
|
-
|
381
|
-
it 'treats each separately scoped account having their own separate balances' do
|
382
|
-
DoubleEntry.transfer(Money.new(20_00), :from => bank, :to => johns_cash, :code => :xfer)
|
383
|
-
DoubleEntry.transfer(Money.new(10_00), :from => bank, :to => ryans_cash, :code => :xfer)
|
384
|
-
expect(johns_cash.balance).to eq(Money.new(20_00))
|
385
|
-
expect(ryans_cash.balance).to eq(Money.new(10_00))
|
386
|
-
end
|
387
|
-
|
388
|
-
it 'allows transfer between two separately scoped accounts' do
|
389
|
-
DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => johns_cash, :code => :xfer)
|
390
|
-
expect(ryans_cash.balance).to eq(Money.new(-10_00))
|
391
|
-
expect(johns_cash.balance).to eq(Money.new(10_00))
|
392
|
-
end
|
393
|
-
|
394
|
-
it 'reports balance correctly if called from either account or finances object' do
|
395
|
-
DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => johns_cash, :code => :xfer)
|
396
|
-
expect(ryans_cash.balance).to eq(Money.new(-10_00))
|
397
|
-
expect(DoubleEntry.balance(:cash, :scope => ryan)).to eq(Money.new(-10_00))
|
398
|
-
end
|
399
|
-
|
400
|
-
it 'raises an exception if you try to scope with an object instance of differing class to that defined on the account' do
|
401
|
-
not_a_user = double(:id => 7)
|
402
|
-
|
403
|
-
expect do
|
404
|
-
DoubleEntry.account(:savings, :scope => not_a_user)
|
405
|
-
end.to raise_error DoubleEntry::AccountScopeMismatchError
|
406
|
-
|
407
|
-
expect do
|
408
|
-
DoubleEntry.balance(:savings, :scope => not_a_user)
|
409
|
-
end.to raise_error DoubleEntry::AccountScopeMismatchError
|
410
|
-
end
|
411
|
-
|
412
|
-
it 'raises exception if you try to transfer between the same account, despite it being scoped' do
|
413
|
-
expect do
|
414
|
-
DoubleEntry.transfer(Money.new(10_00), :from => ryans_cash, :to => ryans_cash, :code => :xfer)
|
415
|
-
end.to raise_error(DoubleEntry::TransferNotAllowed)
|
416
|
-
end
|
417
|
-
|
418
|
-
it 'allows transfer from one persons account to the same persons other kind of account' do
|
419
|
-
DoubleEntry.transfer(Money.new(100_00), :from => ryans_cash, :to => ryans_savings, :code => :xfer)
|
420
|
-
expect(ryans_cash.balance).to eq(Money.new(-100_00))
|
421
|
-
expect(ryans_savings.balance).to eq(Money.new(100_00))
|
422
|
-
end
|
423
|
-
|
424
|
-
it 'disallows you to report on scoped accounts globally' do
|
425
|
-
expect { DoubleEntry.balance(:cash) }.to raise_error DoubleEntry::UnknownAccount
|
426
|
-
end
|
427
|
-
end
|
428
|
-
end
|