mudrat_projector 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.ruby-version +1 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +49 -0
- data/README.md +46 -0
- data/Rakefile +26 -0
- data/bin/testrb +3 -0
- data/finance_fu.txt +41 -0
- data/lib/mudrat_projector/account.rb +75 -0
- data/lib/mudrat_projector/amortizer.rb +103 -0
- data/lib/mudrat_projector/banker_rounding.rb +11 -0
- data/lib/mudrat_projector/chart_of_accounts.rb +119 -0
- data/lib/mudrat_projector/date_diff.rb +169 -0
- data/lib/mudrat_projector/projection.rb +45 -0
- data/lib/mudrat_projector/projector.rb +71 -0
- data/lib/mudrat_projector/schedule.rb +45 -0
- data/lib/mudrat_projector/scheduled_transaction.rb +45 -0
- data/lib/mudrat_projector/tax_calculation.rb +154 -0
- data/lib/mudrat_projector/tax_calculator.rb +144 -0
- data/lib/mudrat_projector/tax_values_by_year.yml +102 -0
- data/lib/mudrat_projector/transaction.rb +71 -0
- data/lib/mudrat_projector/transaction_entry.rb +126 -0
- data/lib/mudrat_projector/transaction_handler.rb +19 -0
- data/lib/mudrat_projector/validator.rb +49 -0
- data/lib/mudrat_projector/version.rb +3 -0
- data/lib/mudrat_projector.rb +27 -0
- data/mudrat_projector.gemspec +28 -0
- data/test/integrations/long_term_projection_test.rb +42 -0
- data/test/integrations/mortgage_test.rb +85 -0
- data/test/integrations/self_employed_tax_calculation_test.rb +44 -0
- data/test/models/accounts_test.rb +39 -0
- data/test/models/chart_of_accounts_test.rb +170 -0
- data/test/models/date_diff_test.rb +117 -0
- data/test/models/projection_test.rb +62 -0
- data/test/models/projector_test.rb +168 -0
- data/test/models/schedule_test.rb +68 -0
- data/test/models/scheduled_transaction_test.rb +47 -0
- data/test/models/tax_calculator_test.rb +145 -0
- data/test/models/transaction_entry_test.rb +37 -0
- data/test/models/transaction_handler_test.rb +67 -0
- data/test/models/transaction_test.rb +66 -0
- data/test/models/validator_test.rb +45 -0
- data/test/test_helper.rb +69 -0
- metadata +204 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 27f998c4d27ead6cc375a7fb9fbde9d41851189e
|
4
|
+
data.tar.gz: 91a01f95d654f29d459cbc94698fb0bb75997ecf
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4c8fcab59b9a618c9b9415010703ab4d1d2c8d8a13b9a221fc3eaa26d04680612b948f2588f39cc2431a33ef1f500b169fe555731a8755a7e479415e241fc1ed
|
7
|
+
data.tar.gz: fd954a625bd5d802c9563044a11360d4dba16e1a14e81f4d8250816bb95b577a554dbec765beed4f7e7e20e4422b76bae2218f9293a9788a96b2e75be0874838
|
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.0.0-p353
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
mudrat_projector (0.0.1)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
ansi (1.4.3)
|
10
|
+
builder (3.2.2)
|
11
|
+
coderay (1.1.0)
|
12
|
+
docile (1.1.1)
|
13
|
+
hashie (2.0.5)
|
14
|
+
method_source (0.8.2)
|
15
|
+
minitest (5.0.8)
|
16
|
+
minitest-reporters (1.0.0)
|
17
|
+
ansi
|
18
|
+
builder
|
19
|
+
minitest (>= 5.0)
|
20
|
+
powerbar
|
21
|
+
multi_json (1.8.2)
|
22
|
+
powerbar (1.0.11)
|
23
|
+
ansi (~> 1.4.0)
|
24
|
+
hashie (>= 1.1.0)
|
25
|
+
pry (0.9.12.4)
|
26
|
+
coderay (~> 1.0)
|
27
|
+
method_source (~> 0.8)
|
28
|
+
slop (~> 3.4)
|
29
|
+
rake (10.1.0)
|
30
|
+
simplecov (0.8.2)
|
31
|
+
docile (~> 1.1.0)
|
32
|
+
multi_json
|
33
|
+
simplecov-html (~> 0.8.0)
|
34
|
+
simplecov-html (0.8.0)
|
35
|
+
slop (3.4.7)
|
36
|
+
timecop (0.7.0)
|
37
|
+
|
38
|
+
PLATFORMS
|
39
|
+
ruby
|
40
|
+
|
41
|
+
DEPENDENCIES
|
42
|
+
bundler (~> 1.3)
|
43
|
+
minitest
|
44
|
+
minitest-reporters
|
45
|
+
mudrat_projector!
|
46
|
+
pry
|
47
|
+
rake
|
48
|
+
simplecov
|
49
|
+
timecop
|
data/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
[](https://travis-ci.org/ntl/mudrat-projector)
|
2
|
+
|
3
|
+
# MudratProjector
|
4
|
+
|
5
|
+
Simple financial projection built in ruby.
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
include MudratProjector
|
9
|
+
|
10
|
+
projector = Projector.new from: "1/1/2000"
|
11
|
+
projector.add_account :checking, type: :asset
|
12
|
+
projector.add_account :uncle_vinnie, type: :revenue
|
13
|
+
projector.add_transaction(
|
14
|
+
date: "7/4/2000",
|
15
|
+
debit: { amount: 6000, account_id: :checking },
|
16
|
+
credit: { amount: 6000, account_id: :uncle_vinnie }
|
17
|
+
)
|
18
|
+
projector.project to: "12/31/2000"
|
19
|
+
assert_equal 5000, projector.account_balance(:checking)
|
20
|
+
```
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
|
24
|
+
Add this line to your application's Gemfile:
|
25
|
+
|
26
|
+
gem 'mudrat_projector'
|
27
|
+
|
28
|
+
And then execute:
|
29
|
+
|
30
|
+
$ bundle
|
31
|
+
|
32
|
+
Or install it yourself as:
|
33
|
+
|
34
|
+
$ gem install mudrat_projector
|
35
|
+
|
36
|
+
## Usage
|
37
|
+
|
38
|
+
TODO: Write usage instructions here
|
39
|
+
|
40
|
+
## Contributing
|
41
|
+
|
42
|
+
1. Fork it
|
43
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
44
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
45
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
46
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require "rake/testtask"
|
2
|
+
|
3
|
+
ENV['COVERAGE'] = '1'
|
4
|
+
|
5
|
+
Rake::TestTask.new do |t|
|
6
|
+
t.pattern = 'test/**/*_test.rb'
|
7
|
+
t.libs << 'test'
|
8
|
+
end
|
9
|
+
|
10
|
+
desc "Update ctags"
|
11
|
+
task :ctags do
|
12
|
+
`ctags -R --languages=Ruby --totals -f tags`
|
13
|
+
end
|
14
|
+
|
15
|
+
task :environment do
|
16
|
+
$LOAD_PATH.push File.expand_path('../lib', __FILE__)
|
17
|
+
require 'mudrat_projector'
|
18
|
+
end
|
19
|
+
|
20
|
+
desc "Open a pry console"
|
21
|
+
task :console => :environment do
|
22
|
+
require 'pry'
|
23
|
+
MudratProjector.pry
|
24
|
+
end
|
25
|
+
|
26
|
+
task default: :test
|
data/bin/testrb
ADDED
data/finance_fu.txt
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
|
4
|
+
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
|
9
|
+
|
10
|
+
Scenario
|
11
|
+
|
12
|
+
Income
|
13
|
+
Self employed
|
14
|
+
Salaried
|
15
|
+
Expenses
|
16
|
+
Recurring
|
17
|
+
One time
|
18
|
+
Liabilities
|
19
|
+
Mortgage, student loan debt, etc.
|
20
|
+
Investments
|
21
|
+
Property
|
22
|
+
Stock
|
23
|
+
Mutual funds, etc.
|
24
|
+
|
25
|
+
|
26
|
+
|
27
|
+
Bank/Financial transaction history
|
28
|
+
|
29
|
+
Can reconcile against active scenario
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
Projection
|
34
|
+
|
35
|
+
Creates ledgers using bank transaction history when available, active scenario on fallback
|
36
|
+
Changes to active scenario immediately propogate to projection
|
37
|
+
Calculates taxes and adjusts for inflation
|
38
|
+
When scenario changes, we store 1 year, 2 year, 5 year, etc.
|
39
|
+
|
40
|
+
|
41
|
+
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class Account
|
3
|
+
TYPES = %i(asset expense liability revenue equity)
|
4
|
+
|
5
|
+
attr :open_date, :parent_id, :tags, :type
|
6
|
+
|
7
|
+
def initialize params = {}
|
8
|
+
@entries = []
|
9
|
+
@open_date = params[:open_date] || ABSOLUTE_START
|
10
|
+
@offset = 0
|
11
|
+
@opening_balance = params[:opening_balance] || 0
|
12
|
+
@parent_id = params[:parent_id] || nil
|
13
|
+
@tags = params[:tags] || []
|
14
|
+
@type = params.fetch :type
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_entry entry
|
18
|
+
@entries.push entry
|
19
|
+
@offset += entry.delta
|
20
|
+
end
|
21
|
+
|
22
|
+
def balance
|
23
|
+
@opening_balance + @offset
|
24
|
+
end
|
25
|
+
|
26
|
+
def close!
|
27
|
+
freeze
|
28
|
+
return self if closed?
|
29
|
+
self.class.new serialize
|
30
|
+
end
|
31
|
+
|
32
|
+
def closed?
|
33
|
+
@entries.empty?
|
34
|
+
end
|
35
|
+
|
36
|
+
def create_child params = {}
|
37
|
+
new_params = serialize
|
38
|
+
new_params.merge!(
|
39
|
+
opening_balance: params[:opening_balance],
|
40
|
+
parent_id: params.fetch(:parent_id),
|
41
|
+
tags: (tags | Array(params[:tags])),
|
42
|
+
)
|
43
|
+
self.class.new new_params
|
44
|
+
end
|
45
|
+
|
46
|
+
def parent?
|
47
|
+
parent_id.nil? ? false : true
|
48
|
+
end
|
49
|
+
|
50
|
+
def tag? tag_name
|
51
|
+
tags.include? tag_name
|
52
|
+
end
|
53
|
+
|
54
|
+
def serialize
|
55
|
+
hash = { opening_balance: balance }
|
56
|
+
%i(open_date parent_id tags type).each do |attr|
|
57
|
+
value = public_send attr
|
58
|
+
unless default_value? attr, value
|
59
|
+
hash[attr] = value unless Array(value).empty?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
hash
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def default_value? attr, value
|
68
|
+
if attr == :open_date
|
69
|
+
value == ABSOLUTE_START
|
70
|
+
else
|
71
|
+
false
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class Amortizer
|
3
|
+
attr :balance, :projection_range
|
4
|
+
end
|
5
|
+
|
6
|
+
class CompoundInterestAmortizer < Amortizer
|
7
|
+
attr :schedule
|
8
|
+
|
9
|
+
def initialize schedule, projection_range, **params
|
10
|
+
@schedule = schedule
|
11
|
+
@projection_range = projection_range
|
12
|
+
@balance, @interest, @principal = amortize
|
13
|
+
end
|
14
|
+
|
15
|
+
def amortize
|
16
|
+
balance = initial_value * ((1 + rate) ** months_amortized)
|
17
|
+
interest = balance - initial_value
|
18
|
+
[balance, interest, 0]
|
19
|
+
end
|
20
|
+
|
21
|
+
def each_entry &block
|
22
|
+
[[:credit, interest, schedule.interest_account ],
|
23
|
+
[:credit, principal, schedule.principal_account],
|
24
|
+
[:debit, payment, schedule.payment_account ]].each &block
|
25
|
+
end
|
26
|
+
|
27
|
+
def initial_value
|
28
|
+
schedule.initial_value
|
29
|
+
end
|
30
|
+
|
31
|
+
def interest
|
32
|
+
@interest.round 2
|
33
|
+
end
|
34
|
+
|
35
|
+
def months_amortized
|
36
|
+
start = [projection_range.begin, schedule_range.begin].max
|
37
|
+
finish = [projection_range.end, schedule_range.end].min
|
38
|
+
DateDiff.date_diff(:month, start, finish).to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
def next_transaction transaction, schedule
|
42
|
+
Transaction.new(
|
43
|
+
date: projection_range.end + 1,
|
44
|
+
credits: transaction.credits,
|
45
|
+
debits: transaction.debits,
|
46
|
+
schedule: schedule,
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
def rate
|
51
|
+
schedule.rate
|
52
|
+
end
|
53
|
+
|
54
|
+
def principal
|
55
|
+
@principal.round 2
|
56
|
+
end
|
57
|
+
|
58
|
+
def payment
|
59
|
+
interest + principal
|
60
|
+
end
|
61
|
+
|
62
|
+
def schedule_range
|
63
|
+
schedule.range
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class MortgageAmortizer < CompoundInterestAmortizer
|
68
|
+
attr :extra_principal, :monthly_payment
|
69
|
+
|
70
|
+
def initialize *args, extra_principal: 0
|
71
|
+
@extra_principal = extra_principal
|
72
|
+
super
|
73
|
+
end
|
74
|
+
|
75
|
+
def monthly_payment
|
76
|
+
schedule.monthly_payment
|
77
|
+
end
|
78
|
+
|
79
|
+
def each_entry &block
|
80
|
+
[[:credit, interest, schedule.payment_account ],
|
81
|
+
[:credit, principal, schedule.payment_account ],
|
82
|
+
[:debit, interest, schedule.interest_account ],
|
83
|
+
[:debit, principal, schedule.principal_account]].each &block
|
84
|
+
end
|
85
|
+
|
86
|
+
def amortize
|
87
|
+
interest_paid = 0
|
88
|
+
principal_paid = 0
|
89
|
+
|
90
|
+
mp = monthly_payment
|
91
|
+
|
92
|
+
new_balance = months_amortized.times.inject initial_value do |balance, _|
|
93
|
+
interest = balance * rate
|
94
|
+
principal = (mp - interest) + extra_principal
|
95
|
+
interest_paid += interest
|
96
|
+
principal_paid += principal
|
97
|
+
balance - principal
|
98
|
+
end
|
99
|
+
|
100
|
+
[new_balance, interest_paid, principal_paid]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
module BankerRounding
|
3
|
+
def with_banker_rounding
|
4
|
+
old_rounding_mode = BigDecimal.mode BigDecimal::ROUND_MODE
|
5
|
+
BigDecimal.mode BigDecimal::ROUND_MODE, BigDecimal::ROUND_HALF_EVEN
|
6
|
+
yield
|
7
|
+
ensure
|
8
|
+
BigDecimal.mode BigDecimal::ROUND_MODE, old_rounding_mode
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
class ChartOfAccounts
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@accounts = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def account_balance id
|
10
|
+
fetch(id).balance + subaccounts_balance(id)
|
11
|
+
end
|
12
|
+
|
13
|
+
def accounts
|
14
|
+
@accounts.keys
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_account id, **params
|
18
|
+
@accounts[id] = Account.new params
|
19
|
+
end
|
20
|
+
|
21
|
+
def apply_transaction transaction
|
22
|
+
validate_transaction! transaction
|
23
|
+
transaction.entries.each do |entry| entry.calculate self; end
|
24
|
+
transaction.entries.each do |entry|
|
25
|
+
fetch(entry.account_id).add_entry entry
|
26
|
+
end
|
27
|
+
transaction
|
28
|
+
end
|
29
|
+
|
30
|
+
def balance
|
31
|
+
inject 0 do |sum, account|
|
32
|
+
if account.parent?
|
33
|
+
sum
|
34
|
+
else
|
35
|
+
method = %i(asset expense).include?(account.type) ? :+ : :-
|
36
|
+
sum.public_send method, account.balance
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def each &block
|
42
|
+
@accounts.values.each &block
|
43
|
+
end
|
44
|
+
|
45
|
+
def exists? account_id
|
46
|
+
@accounts.has_key? account_id
|
47
|
+
end
|
48
|
+
|
49
|
+
def fetch account_id
|
50
|
+
@accounts.fetch account_id
|
51
|
+
end
|
52
|
+
|
53
|
+
def net_worth
|
54
|
+
@accounts.reduce 0 do |sum, (_, account)|
|
55
|
+
if account.type == :asset
|
56
|
+
sum + account.balance
|
57
|
+
elsif account.type == :liability
|
58
|
+
sum - account.balance
|
59
|
+
else
|
60
|
+
sum
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def size
|
66
|
+
@accounts.size
|
67
|
+
end
|
68
|
+
|
69
|
+
def serialize
|
70
|
+
@accounts.reduce Hash.new do |hash, (id, account)|
|
71
|
+
hash[id] = account.serialize
|
72
|
+
hash
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def split_account id, into: {}
|
77
|
+
parent = fetch id
|
78
|
+
into.map do |sub_account_id, hash|
|
79
|
+
@accounts[sub_account_id] =
|
80
|
+
if hash
|
81
|
+
parent.create_child(
|
82
|
+
opening_balance: hash[:amount],
|
83
|
+
parent_id: id,
|
84
|
+
tags: hash[:tags],
|
85
|
+
)
|
86
|
+
else
|
87
|
+
parent.create_child parent_id: id
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def subaccounts_balance id
|
93
|
+
subaccount_ids = @accounts.reduce [] do |ary, (subaccount_id, account)|
|
94
|
+
ary.push subaccount_id if account.parent_id == id
|
95
|
+
ary
|
96
|
+
end
|
97
|
+
subaccount_ids.reduce 0 do |sum, id| sum + account_balance(id); end
|
98
|
+
end
|
99
|
+
|
100
|
+
def validate_transaction! transaction
|
101
|
+
transaction.each do |entry|
|
102
|
+
validate_entry! transaction.date, entry
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def validate_entry! transaction_date, entry
|
107
|
+
unless @accounts.has_key? entry.account_id
|
108
|
+
raise Projector::AccountDoesNotExist, "Transaction references non "\
|
109
|
+
"existent account #{entry.account_id.inspect}"
|
110
|
+
end
|
111
|
+
open_date = fetch(entry.account_id).open_date
|
112
|
+
unless open_date <= transaction_date
|
113
|
+
raise Projector::AccountDoesNotExist, "Transaction references account "\
|
114
|
+
"#{entry.account_id.inspect} which does not open until #{open_date}, "\
|
115
|
+
"but transaction is set for #{transaction_date}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
module MudratProjector
|
2
|
+
module DateDiff
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def advance intervals: nil, unit: nil, from: nil
|
6
|
+
fetch_subclass(unit).advance intervals, from: from
|
7
|
+
end
|
8
|
+
|
9
|
+
def date_diff *maybe_unit_from_to, unit: nil, from: nil, to: nil
|
10
|
+
if [unit, from, to].all? &:nil?
|
11
|
+
unit, from, to = maybe_unit_from_to
|
12
|
+
end
|
13
|
+
fetch_subclass(unit).new(from, to).calculate
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch_subclass unit
|
17
|
+
klass_bit = unit.to_s.capitalize.gsub(/_[a-z]/) do |dash_letter|
|
18
|
+
dash_letter[1].upcase
|
19
|
+
end
|
20
|
+
klass_name = "#{klass_bit}Calculator"
|
21
|
+
DateDiff.const_get klass_name
|
22
|
+
end
|
23
|
+
private :fetch_subclass
|
24
|
+
|
25
|
+
Calculator = Struct.new :from, :to do
|
26
|
+
def calculate
|
27
|
+
fail
|
28
|
+
end
|
29
|
+
|
30
|
+
def days_between
|
31
|
+
(to - from).to_f + 1
|
32
|
+
end
|
33
|
+
private :days_between
|
34
|
+
end
|
35
|
+
|
36
|
+
class DayCalculator < Calculator
|
37
|
+
def calculate
|
38
|
+
days_between
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.advance intervals, from: from
|
42
|
+
from + intervals.round
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class WeekCalculator < DayCalculator
|
47
|
+
def calculate
|
48
|
+
super / 7.0
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.advance intervals, from: from
|
52
|
+
from + (intervals * 7)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class ComplexCalculator < Calculator
|
57
|
+
attr :first_unit, :last_unit
|
58
|
+
|
59
|
+
def initialize *args
|
60
|
+
super
|
61
|
+
@first_unit = fetch_unit from
|
62
|
+
@last_unit = fetch_unit to
|
63
|
+
end
|
64
|
+
|
65
|
+
def calculate
|
66
|
+
if first_unit.begin == last_unit.begin
|
67
|
+
days_between / days_in_unit(first_unit)
|
68
|
+
else
|
69
|
+
calculate_unit(from, first_unit.end) +
|
70
|
+
units_between +
|
71
|
+
calculate_unit(last_unit.begin, to)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def calculate_unit unit_begin, unit_end
|
78
|
+
self.class.new(unit_begin, unit_end).calculate
|
79
|
+
end
|
80
|
+
|
81
|
+
def calculate_units_between start, finish
|
82
|
+
count = 1
|
83
|
+
until start == finish
|
84
|
+
count += 1
|
85
|
+
start = advance_one_unit start
|
86
|
+
end
|
87
|
+
count
|
88
|
+
end
|
89
|
+
|
90
|
+
def days_in_unit unit
|
91
|
+
((unit.end + 1) - unit.begin).to_f
|
92
|
+
end
|
93
|
+
|
94
|
+
def units_between
|
95
|
+
start = first_unit.end + 1
|
96
|
+
finish = rewind_one_unit last_unit.begin
|
97
|
+
return 0 if start > finish
|
98
|
+
calculate_units_between start, finish
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class YearCalculator < ComplexCalculator
|
103
|
+
def fetch_unit date
|
104
|
+
(Date.new(date.year)..Date.new(date.year, 12, 31))
|
105
|
+
end
|
106
|
+
|
107
|
+
def advance_one_unit date
|
108
|
+
date.next_year
|
109
|
+
end
|
110
|
+
|
111
|
+
def rewind_one_unit date
|
112
|
+
date.prev_year
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.advance intervals, from: from
|
116
|
+
Date.new(from.year + intervals, from.month, from.day)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class QuarterCalculator < ComplexCalculator
|
121
|
+
def fetch_unit date
|
122
|
+
[1, 4, 7, 10].each do |quarter|
|
123
|
+
if (quarter..quarter + 2).include? date.month
|
124
|
+
start_of_quarter = Date.new(date.year, quarter)
|
125
|
+
return (start_of_quarter..(start_of_quarter.next_month.next_month.next_month - 1))
|
126
|
+
end
|
127
|
+
end
|
128
|
+
fail "Date month was #{date.month}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def advance_one_unit date
|
132
|
+
date.next_month.next_month.next_month
|
133
|
+
end
|
134
|
+
|
135
|
+
def rewind_one_unit date
|
136
|
+
date.prev_month.prev_month.prev_month
|
137
|
+
end
|
138
|
+
|
139
|
+
def self.advance intervals, from: from
|
140
|
+
(intervals * 3).times.inject from do |date, _| date.next_month; end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class MonthCalculator < ComplexCalculator
|
145
|
+
def fetch_unit date
|
146
|
+
start_of_month = Date.new(date.year, date.month)
|
147
|
+
(start_of_month..(start_of_month.next_month - 1))
|
148
|
+
end
|
149
|
+
|
150
|
+
def advance_one_unit date
|
151
|
+
date.next_month
|
152
|
+
end
|
153
|
+
|
154
|
+
def rewind_one_unit date
|
155
|
+
date.prev_month
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.advance intervals, from: from
|
159
|
+
if intervals < 1
|
160
|
+
days_in_month = Date.new(from.year, from.month, -1).day
|
161
|
+
days = intervals * days_in_month
|
162
|
+
DayCalculator.advance days, from: from
|
163
|
+
else
|
164
|
+
intervals.times.inject from do |date, _| date.next_month; end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|