twobook 0.1.1
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 +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +59 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/twobook/account.rb +167 -0
- data/lib/twobook/account_query.rb +208 -0
- data/lib/twobook/agreement.rb +79 -0
- data/lib/twobook/configuration.rb +18 -0
- data/lib/twobook/corrections.rb +111 -0
- data/lib/twobook/entry.rb +15 -0
- data/lib/twobook/event.rb +87 -0
- data/lib/twobook/event_processing.rb +61 -0
- data/lib/twobook/handler/booking_helpers.rb +67 -0
- data/lib/twobook/handler/query_helpers.rb +83 -0
- data/lib/twobook/handler.rb +59 -0
- data/lib/twobook/number_handling.rb +22 -0
- data/lib/twobook/serialization.rb +82 -0
- data/lib/twobook/utilities.rb +39 -0
- data/lib/twobook/version.rb +3 -0
- data/lib/twobook.rb +19 -0
- data/twobook.gemspec +31 -0
- metadata +155 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
module Twobook
|
2
|
+
module Corrections
|
3
|
+
def self.make_deletion(event, accounts, history, happened_at: Time.current)
|
4
|
+
correct_history = history - [event]
|
5
|
+
|
6
|
+
snapshots = accounts.map do |a|
|
7
|
+
Serialization.serialize_account(a, before_event: event, allow_empty: false)
|
8
|
+
end.compact
|
9
|
+
|
10
|
+
CorrectionMade.new(
|
11
|
+
account_snapshots: snapshots,
|
12
|
+
corrected_events: correct_history.map { |e| Serialization.serialize_event(e) },
|
13
|
+
correction_explanation: { event_uuid: event.uuid, type: 'deletion' },
|
14
|
+
happened_at: happened_at,
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.make_edit(edited_event, accounts, history, happened_at: Time.current)
|
19
|
+
index = history.index(edited_event)
|
20
|
+
correct_history = history.deep_dup
|
21
|
+
correct_history[index] = edited_event
|
22
|
+
|
23
|
+
snapshots = accounts.map do |a|
|
24
|
+
Serialization.serialize_account(a, before_event: edited_event, allow_empty: false)
|
25
|
+
end.compact
|
26
|
+
|
27
|
+
CorrectionMade.new(
|
28
|
+
account_snapshots: snapshots,
|
29
|
+
corrected_events: correct_history.map { |e| Serialization.serialize_event(e) },
|
30
|
+
correction_explanation: { event_uuid: edited_event.uuid, type: 'edit', new_parameters: edited_event.data },
|
31
|
+
happened_at: happened_at,
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
class CorrectionBuffer < Twobook::Account
|
36
|
+
account_type :assets
|
37
|
+
end
|
38
|
+
|
39
|
+
class CorrectionMade < Twobook::Event
|
40
|
+
has :account_snapshots, :corrected_events, :correction_explanation
|
41
|
+
|
42
|
+
def fetch_agreements!
|
43
|
+
[Correction.new]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class SimulatedDifferenceAdjustment < Handler
|
48
|
+
def handle(account_snapshots:, corrected_events:)
|
49
|
+
correct_accounts = self.class.simulate_correction(corrected_events, account_snapshots)
|
50
|
+
|
51
|
+
correct_accounts.each do |correct|
|
52
|
+
original = where(name: correct.name).execute(@accounts_in_process).first
|
53
|
+
adjust_original_account_balance(original, correct)
|
54
|
+
adjust_original_account_data(original, correct)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def adjust_original_account_balance(original, correct)
|
59
|
+
correct_balance = correct.balance || Twobook.wrap_number(0)
|
60
|
+
original_balance = original.balance || Twobook.wrap_number(0)
|
61
|
+
diff = correct_balance - original_balance
|
62
|
+
return if diff.zero?
|
63
|
+
|
64
|
+
if original.class.account_type == :records
|
65
|
+
record original, amount: diff
|
66
|
+
else
|
67
|
+
diff *= -1 if %i(revenue liabilities).include?(original.class.account_type)
|
68
|
+
book diff, cr: buffer_account, dr: original if diff.positive?
|
69
|
+
book (-1 * diff), cr: original, dr: buffer_account if diff.negative?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def adjust_original_account_data(original, correct)
|
74
|
+
diff = correct.data.to_a - original.data.to_a
|
75
|
+
return if diff.empty?
|
76
|
+
original << entry(0, data: diff.to_h)
|
77
|
+
end
|
78
|
+
|
79
|
+
def accounts(corrected_events:, account_snapshots:)
|
80
|
+
corrected_accounts = self.class.simulate_correction(corrected_events, account_snapshots)
|
81
|
+
|
82
|
+
requirements = corrected_accounts.map do |account|
|
83
|
+
query = AccountQuery.where(
|
84
|
+
category: account.class.category,
|
85
|
+
**account.data.slice(*account.class.name_includes),
|
86
|
+
)
|
87
|
+
existing(query)
|
88
|
+
end
|
89
|
+
|
90
|
+
labelled_requirements = (0...requirements.count).map do |n|
|
91
|
+
"requirement_#{n}_account".to_sym
|
92
|
+
end.zip(requirements).to_h
|
93
|
+
|
94
|
+
{
|
95
|
+
buffer_account: one(where(category: 'twobook/corrections/correction_buffer')),
|
96
|
+
**labelled_requirements,
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.simulate_correction(events, accounts)
|
101
|
+
deserialized_events = events.map { |e| Serialization.deserialize_event(e) }
|
102
|
+
deserialized_accounts = accounts.map { |a| Serialization.deserialize_account(a) }
|
103
|
+
Twobook.simulate(deserialized_events, deserialized_accounts)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
class Correction < Agreement
|
108
|
+
handles 'twobook/corrections/correction_made', with: 'twobook/corrections/simulated_difference_adjustment'
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Twobook
|
2
|
+
class Entry
|
3
|
+
attr_reader :amount, :event, :transaction_id, :account, :data
|
4
|
+
|
5
|
+
def initialize(amount, event, transaction_id: nil, data: {})
|
6
|
+
@amount = Twobook.wrap_number(amount)
|
7
|
+
|
8
|
+
raise 'Required an Event' unless event.is_a?(Event)
|
9
|
+
@event = event
|
10
|
+
|
11
|
+
@transaction_id = transaction_id
|
12
|
+
@data = data
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Twobook
|
2
|
+
class Event
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
attr_reader :data, :happened_at, :uuid, :partial_order
|
6
|
+
attr_accessor :entries, :agreements
|
7
|
+
|
8
|
+
def initialize(happened_at: Time.current, uuid: SecureRandom.uuid, **data)
|
9
|
+
@happened_at = happened_at
|
10
|
+
@data = data
|
11
|
+
@uuid = uuid
|
12
|
+
@agreements = []
|
13
|
+
@entries = []
|
14
|
+
|
15
|
+
remaining_keys_to_match = self.class.has.deep_dup
|
16
|
+
@data.each do |k, v|
|
17
|
+
raise "Cannot initialize event #{inspect}: unexpected parameter #{k}" if remaining_keys_to_match.delete(k).nil?
|
18
|
+
raise "Cannot initialize event #{inspect}: #{k} is nil" if v.nil?
|
19
|
+
|
20
|
+
@data[k] = Twobook.wrap_number(v) if v.is_a?(Numeric)
|
21
|
+
|
22
|
+
define_singleton_method k, -> { @data.dig(k) }
|
23
|
+
end
|
24
|
+
raise "Cannot initialize event #{inspect}: required #{remaining_keys_to_match}" if remaining_keys_to_match.any?
|
25
|
+
end
|
26
|
+
|
27
|
+
def clone
|
28
|
+
c = super
|
29
|
+
c.instance_variable_set(:@entries, @entries.map(&:clone))
|
30
|
+
c.instance_variable_set(:@data, @data.deep_dup)
|
31
|
+
c
|
32
|
+
end
|
33
|
+
|
34
|
+
def fetch_agreements!
|
35
|
+
raise "I don't know how to fetch the agreements for #{@name}"
|
36
|
+
end
|
37
|
+
|
38
|
+
def fetch_and_assign_agreements!
|
39
|
+
@agreements = fetch_agreements!
|
40
|
+
self
|
41
|
+
end
|
42
|
+
alias load! fetch_and_assign_agreements!
|
43
|
+
|
44
|
+
def inspect
|
45
|
+
"<#{self.class.name} @data=#{@data} @happened_at=#{@happened_at}>"
|
46
|
+
end
|
47
|
+
|
48
|
+
def update_partial_order(i)
|
49
|
+
@partial_order = i
|
50
|
+
self
|
51
|
+
end
|
52
|
+
|
53
|
+
def update_happened_at(happened_at)
|
54
|
+
@happened_at = happened_at
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
def ==(other)
|
59
|
+
other.is_a?(Event) && @uuid == other.uuid
|
60
|
+
end
|
61
|
+
|
62
|
+
def <=>(other)
|
63
|
+
return @happened_at <=> other if other.is_a?(Time)
|
64
|
+
[@happened_at, @partial_order || 0] <=> [other.happened_at, other.partial_order || 0]
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.event_name
|
68
|
+
name.underscore.gsub("#{Twobook.configuration.accounting_namespace.underscore}/events/", '')
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.from_name(name)
|
72
|
+
match = types.detect { |t| t.name =~ /#{name.camelize}$/ }
|
73
|
+
raise "Bad event name #{name}" unless match
|
74
|
+
match
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.types
|
78
|
+
Utilities.types(self)
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.has(*args)
|
82
|
+
@has ||= []
|
83
|
+
return @has if args.empty?
|
84
|
+
@has += args
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Twobook
|
2
|
+
def self.simulate(event, input_accounts = [])
|
3
|
+
return simulate_chain(event, input_accounts) if event.is_a?(Array)
|
4
|
+
raise 'Cannot simulate: event has no agreements' unless event.agreements.any?
|
5
|
+
|
6
|
+
handlers = event.agreements.reduce([]) do |memo, agreement|
|
7
|
+
memo + agreement.handlers_for(event)
|
8
|
+
end
|
9
|
+
|
10
|
+
new_accounts = handlers.reduce(Set.new(input_accounts)) do |processing_accounts, handler|
|
11
|
+
handler.run(processing_accounts)
|
12
|
+
end
|
13
|
+
|
14
|
+
# All entries added while processesing that event should satisfy the accounting equation.
|
15
|
+
ensure_transaction! new_accounts, event
|
16
|
+
|
17
|
+
new_accounts
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.simulate_chain(events, input_accounts = [])
|
21
|
+
sorted_groups = events.group_by(&:happened_at)
|
22
|
+
sorted = []
|
23
|
+
sorted_groups.keys.sort.each do |time|
|
24
|
+
group = sorted_groups[time]
|
25
|
+
# Assign order to groups of events with the same timestamp according to the order passed in
|
26
|
+
sorted_group = if group.any? { |event| event.partial_order.nil? }
|
27
|
+
group.map.with_index { |event, i| event.update_partial_order(i) }
|
28
|
+
else
|
29
|
+
group.sort_by(&:partial_order)
|
30
|
+
end
|
31
|
+
sorted_group.each { |event| sorted << event }
|
32
|
+
end
|
33
|
+
|
34
|
+
sorted.reduce(input_accounts) do |accounts, event|
|
35
|
+
simulate(event, accounts)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.ensure_transaction!(accounts, event)
|
40
|
+
assets = sum_entries_under_account_type(:assets, accounts, event)
|
41
|
+
liabilities = sum_entries_under_account_type(:liabilities, accounts, event)
|
42
|
+
revenue = sum_entries_under_account_type(:revenue, accounts, event)
|
43
|
+
expenses = sum_entries_under_account_type(:expenses, accounts, event)
|
44
|
+
|
45
|
+
sum = assets - liabilities - revenue + expenses
|
46
|
+
|
47
|
+
if sum.nonzero?
|
48
|
+
report = accounts.map do |a|
|
49
|
+
"#{a.name}: #{a.entries.select { |e| e.event == event }.map(&:amount).sum}"
|
50
|
+
end.join("\n")
|
51
|
+
|
52
|
+
raise "Invalid transaction: must sum to zero, but summed to #{sum}. \n#{report}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.sum_entries_under_account_type(type, accounts, event)
|
57
|
+
accounts.select { |a| a.class.account_type == type }.map do |a|
|
58
|
+
a.entries.select { |e| e.event == event }.map(&:amount).sum
|
59
|
+
end.sum
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Twobook
|
2
|
+
class Handler
|
3
|
+
# Mixin for Handler with some booking shorthand.
|
4
|
+
# Expects @account_in_process and @event_in_process to be set.
|
5
|
+
module BookingHelpers
|
6
|
+
def entry(amount, transaction_id: nil, data: {})
|
7
|
+
raise 'Cannot create entry - not currently processing an event' if @event_in_process.blank?
|
8
|
+
new_entry = Entry.new(amount, @event_in_process, transaction_id: transaction_id, data: data)
|
9
|
+
@event_in_process.entries << new_entry
|
10
|
+
new_entry
|
11
|
+
end
|
12
|
+
|
13
|
+
def record(account, amount: 0, **data)
|
14
|
+
account << entry(amount, data: data)
|
15
|
+
end
|
16
|
+
|
17
|
+
def book(amount, debit: nil, dr: nil, credit: nil, cr: nil)
|
18
|
+
to_debit = debit || dr
|
19
|
+
to_credit = credit || cr
|
20
|
+
raise 'Must credit one account and debit one account' unless to_debit && to_credit
|
21
|
+
transaction_id = SecureRandom.uuid
|
22
|
+
debit amount, to_debit, transaction_id: transaction_id
|
23
|
+
credit amount, to_credit, transaction_id: transaction_id
|
24
|
+
end
|
25
|
+
|
26
|
+
def debit(amount, account, **opts)
|
27
|
+
case account.class.account_type
|
28
|
+
when :assets
|
29
|
+
account << entry(amount, **opts)
|
30
|
+
when :liabilities
|
31
|
+
account << entry(-amount, **opts)
|
32
|
+
when :revenue
|
33
|
+
account << entry(-amount, **opts)
|
34
|
+
when :expenses
|
35
|
+
account << entry(amount, **opts)
|
36
|
+
else
|
37
|
+
raise "Invalid account type #{account.account_type}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def credit(amount, account, **opts)
|
42
|
+
case account.class.account_type
|
43
|
+
when :assets
|
44
|
+
account << entry(-amount, **opts)
|
45
|
+
when :liabilities
|
46
|
+
account << entry(amount, **opts)
|
47
|
+
when :revenue
|
48
|
+
account << entry(amount, **opts)
|
49
|
+
when :expenses
|
50
|
+
account << entry(-amount, **opts)
|
51
|
+
else
|
52
|
+
raise "Invalid account type #{account.account_type}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def add_account(account)
|
57
|
+
raise 'Cannot add accounts: not currently processing accounts' if @accounts_in_process.nil?
|
58
|
+
if @accounts_in_process.include?(account)
|
59
|
+
raise "Cannot add account #{account.name}: was already processing one with the same name."
|
60
|
+
end
|
61
|
+
|
62
|
+
@accounts_in_process << account
|
63
|
+
account
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Twobook
|
2
|
+
class Handler
|
3
|
+
# Mixin for Handler with some query shorthand.
|
4
|
+
# Expects @accounts_in_process to be set when running a handler
|
5
|
+
# Expects @data_in_process to be set when looking up account requirements
|
6
|
+
module QueryHelpers
|
7
|
+
def one(query)
|
8
|
+
{ requested: :one, query: query.convert_to_name_query }
|
9
|
+
end
|
10
|
+
|
11
|
+
def existing(query)
|
12
|
+
{ requested: :existing, query: query.convert_to_name_query }
|
13
|
+
end
|
14
|
+
|
15
|
+
def many(query)
|
16
|
+
{ requested: :many, query: query }
|
17
|
+
end
|
18
|
+
|
19
|
+
def where(constraints)
|
20
|
+
AccountQuery.where(constraints)
|
21
|
+
end
|
22
|
+
|
23
|
+
def satisfy_requirement(requested:, query:)
|
24
|
+
accounts = query.execute(@accounts_in_process)
|
25
|
+
|
26
|
+
case requested
|
27
|
+
when :one
|
28
|
+
existing = accounts.first
|
29
|
+
return existing if existing.present?
|
30
|
+
new = query.construct_account
|
31
|
+
@accounts_in_process << new
|
32
|
+
new
|
33
|
+
when :existing
|
34
|
+
it = accounts.first
|
35
|
+
if it.nil?
|
36
|
+
raise "Cannot process #{@event_in_process.inspect} with #{inspect}: " \
|
37
|
+
"no match for query #{query.inspect}). I have #{@accounts_in_process.join(', ')}"
|
38
|
+
|
39
|
+
end
|
40
|
+
it
|
41
|
+
when :many
|
42
|
+
Twobook.wrap_account_list!(accounts)
|
43
|
+
else
|
44
|
+
raise "Cannot satisfy requirement request #{requested}: not supported"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def respond_to_missing?(account_name, *_)
|
49
|
+
account_name.to_s =~ /_accounts?$/
|
50
|
+
end
|
51
|
+
|
52
|
+
def method_missing(account_label, *_)
|
53
|
+
super unless account_label.to_s =~ /_accounts?$/
|
54
|
+
super if @event_in_process.blank?
|
55
|
+
|
56
|
+
requirement = labelled_account_requirements.dig(account_label)
|
57
|
+
super unless requirement
|
58
|
+
|
59
|
+
satisfy_requirement(requirement)
|
60
|
+
end
|
61
|
+
|
62
|
+
def labelled_account_requirements
|
63
|
+
them = Utilities.match_params(
|
64
|
+
method(:accounts), @data_in_process, "Cannot generate accounts for #{inspect} with data #{@data_in_process}"
|
65
|
+
)
|
66
|
+
|
67
|
+
them.keys.map(&:to_s).each do |k|
|
68
|
+
raise "Invalid account label #{k} for #{inspect} (must end in _account(s))" unless k =~ /_accounts?/
|
69
|
+
end
|
70
|
+
|
71
|
+
them
|
72
|
+
end
|
73
|
+
|
74
|
+
def account_requirements
|
75
|
+
labelled_account_requirements.values
|
76
|
+
end
|
77
|
+
|
78
|
+
def accounts(*_)
|
79
|
+
{}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require_relative 'handler/query_helpers'
|
2
|
+
require_relative 'handler/booking_helpers'
|
3
|
+
|
4
|
+
module Twobook
|
5
|
+
class Handler
|
6
|
+
include QueryHelpers
|
7
|
+
include BookingHelpers
|
8
|
+
|
9
|
+
attr_reader :event_in_process, :data_in_process
|
10
|
+
|
11
|
+
def initialize(event:, **data)
|
12
|
+
raise 'Must be initialized with an event' unless event.is_a?(Event)
|
13
|
+
@event_in_process = event
|
14
|
+
@data_in_process = {
|
15
|
+
**data,
|
16
|
+
**event.data,
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
def run(accounts)
|
21
|
+
raise 'No event set; was this handler initialized properly?' unless @event_in_process.present?
|
22
|
+
@accounts_in_process = accounts.map(&:clone)
|
23
|
+
|
24
|
+
Utilities.match_params(
|
25
|
+
method(:handle),
|
26
|
+
{
|
27
|
+
**@data_in_process,
|
28
|
+
happened_at: @event_in_process.happened_at,
|
29
|
+
event_name: @event_in_process.class.event_name,
|
30
|
+
},
|
31
|
+
"Cannot run handler #{self.class.handler_name} for event #{@event_in_process}",
|
32
|
+
)
|
33
|
+
|
34
|
+
@accounts_in_process
|
35
|
+
end
|
36
|
+
|
37
|
+
def handle
|
38
|
+
raise 'This handler needs a #handle method'
|
39
|
+
end
|
40
|
+
|
41
|
+
def name
|
42
|
+
self.class.handler_name
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.handler_name
|
46
|
+
name.underscore.gsub("#{Twobook.configuration.accounting_namespace.underscore}/handlers/", '')
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.from_name(name)
|
50
|
+
match = types.detect { |t| t.name =~ /#{name.camelize}$/ }
|
51
|
+
raise "Bad handler name: #{name}" unless match
|
52
|
+
match
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.types
|
56
|
+
Utilities.types(self)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
class BigDecimal
|
4
|
+
# Makes it easier to work with these in a repl
|
5
|
+
def inspect
|
6
|
+
"#<BigDecimal '#{to_f}'>"
|
7
|
+
end
|
8
|
+
|
9
|
+
def as_json(*_)
|
10
|
+
truncate(Twobook::SCALE).to_f
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module Twobook
|
15
|
+
::BigDecimal.mode(::BigDecimal::ROUND_MODE, ::BigDecimal::ROUND_HALF_EVEN)
|
16
|
+
PRECISION = 15 # significant figures
|
17
|
+
SCALE = 6 # decimals after the point
|
18
|
+
|
19
|
+
def self.wrap_number(n)
|
20
|
+
::BigDecimal.new(n, PRECISION).round(SCALE)
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Twobook
|
2
|
+
module Serialization
|
3
|
+
def self.serialize_event(event)
|
4
|
+
{
|
5
|
+
name: event.class.event_name,
|
6
|
+
data: event.data,
|
7
|
+
agreements: event.agreements.map { |a| serialize_agreement(a) },
|
8
|
+
}.as_json
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.deserialize_event(serialized)
|
12
|
+
serialized.deep_symbolize_keys!
|
13
|
+
|
14
|
+
klass = Event.from_name(serialized[:name])
|
15
|
+
event = klass.new(
|
16
|
+
**deserialize_data(serialized[:data]),
|
17
|
+
)
|
18
|
+
|
19
|
+
event.agreements = serialized[:agreements].map { |a| deserialize_agreement(a) }
|
20
|
+
event
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.serialize_agreement(agreement)
|
24
|
+
{
|
25
|
+
name: agreement.class.agreement_name,
|
26
|
+
data: agreement.data,
|
27
|
+
}.as_json
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.deserialize_agreement(serialized)
|
31
|
+
serialized.deep_symbolize_keys!
|
32
|
+
klass = Agreement.from_name(serialized[:name])
|
33
|
+
klass.new(**deserialize_data(serialized[:data]))
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.serialize_account(account, before_event: nil, allow_empty: true)
|
37
|
+
balance, data = if before_event.nil?
|
38
|
+
[account.balance, account.data]
|
39
|
+
else
|
40
|
+
[
|
41
|
+
account.balance_before_event(before_event),
|
42
|
+
account.data_before_event(before_event),
|
43
|
+
]
|
44
|
+
end
|
45
|
+
|
46
|
+
# If we don't allow empty accounts...
|
47
|
+
unless allow_empty
|
48
|
+
only_has_name_data = data.keys.all? { |key| key.in?(account.class.name_includes) }
|
49
|
+
return nil if balance.zero? && only_has_name_data
|
50
|
+
end
|
51
|
+
|
52
|
+
{
|
53
|
+
name: account.name,
|
54
|
+
balance: balance,
|
55
|
+
data: data,
|
56
|
+
}.as_json
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.deserialize_account(serialized)
|
60
|
+
serialized.deep_symbolize_keys!
|
61
|
+
klass = Account.from_name(serialized[:name])
|
62
|
+
immutable_data = deserialize_data(serialized[:data].slice(*klass.name_includes))
|
63
|
+
mutable_data = deserialize_data(serialized[:data].slice(*klass.has))
|
64
|
+
account = klass.new(balance: serialized[:balance], **immutable_data)
|
65
|
+
account.update_mutable_data(mutable_data)
|
66
|
+
account
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.deserialize_data(data)
|
70
|
+
converted = data.deep_symbolize_keys
|
71
|
+
converted.each { |k, v| converted[k] = try_parse_date(v) if k.to_s.end_with?("_at") }
|
72
|
+
converted
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.try_parse_date(string)
|
76
|
+
return string if string.is_a?(Time)
|
77
|
+
Time.zone.parse(string)
|
78
|
+
rescue => _
|
79
|
+
raise "Could not convert _at data on account #{inspect}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Twobook
|
2
|
+
module Utilities
|
3
|
+
def self.match_params(method, available, error_message)
|
4
|
+
required = method.parameters.select { |p| p.first == :keyreq }.map(&:second)
|
5
|
+
optional = method.parameters.select { |p| p.first == :key }.map(&:second)
|
6
|
+
|
7
|
+
if method.parameters.any? { |p| p.first == :req || p.first == :opt }
|
8
|
+
raise "match_params only works with named parameters (was given #{method.parameters})"
|
9
|
+
end
|
10
|
+
|
11
|
+
missing = required - available.keys
|
12
|
+
raise "#{error_message} - missing parameters #{missing}" if missing.any?
|
13
|
+
|
14
|
+
params = available.slice(*(required + optional))
|
15
|
+
if params.any?
|
16
|
+
method.call(params)
|
17
|
+
else
|
18
|
+
method.call
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Lists all the leaf node descendants of a class.
|
23
|
+
# The files must already be loaded.
|
24
|
+
def self.types(klass, cache = false)
|
25
|
+
if cache
|
26
|
+
@types_cache ||= {}
|
27
|
+
return @types_cache[klass.name] ||= begin
|
28
|
+
klass.descendants
|
29
|
+
.reject { |k| k.name.nil? || k.subclasses.reject { |s| s.name.nil? }.any? }
|
30
|
+
.sort_by(&:name)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
klass.descendants
|
35
|
+
.reject { |k| k.name.nil? || k.subclasses.reject { |s| s.name.nil? }.any? }
|
36
|
+
.sort_by(&:name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/twobook.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'twobook/version'
|
2
|
+
require 'twobook/configuration'
|
3
|
+
require 'active_support/inflector'
|
4
|
+
require 'active_support/core_ext/object'
|
5
|
+
require 'active_support/core_ext/numeric/time'
|
6
|
+
require 'active_support/core_ext/array/access'
|
7
|
+
require 'active_support/core_ext/class/subclasses'
|
8
|
+
require 'active_support/core_ext/enumerable'
|
9
|
+
require 'twobook/number_handling'
|
10
|
+
require 'twobook/utilities'
|
11
|
+
require 'twobook/entry'
|
12
|
+
require 'twobook/account'
|
13
|
+
require 'twobook/account_query'
|
14
|
+
require 'twobook/event'
|
15
|
+
require 'twobook/handler'
|
16
|
+
require 'twobook/agreement'
|
17
|
+
require 'twobook/event_processing'
|
18
|
+
require 'twobook/corrections'
|
19
|
+
require 'twobook/serialization'
|
data/twobook.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'twobook/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'twobook'
|
8
|
+
spec.version = Twobook::VERSION
|
9
|
+
spec.authors = ['Michael Parry']
|
10
|
+
spec.email = ['parry.my@gmail.com']
|
11
|
+
|
12
|
+
spec.summary = 'Double-entry accounting with superpowers'
|
13
|
+
spec.description = 'Database-optional double-entry accounting system with built-in corrections'
|
14
|
+
spec.homepage = 'https://github.com/parry-my/twobook'
|
15
|
+
spec.license = 'MIT'
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(test|spec|features)/})
|
19
|
+
end
|
20
|
+
spec.bindir = 'exe'
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ['lib']
|
23
|
+
|
24
|
+
spec.add_development_dependency 'bundler', '~> 1.15'
|
25
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
26
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
27
|
+
spec.add_development_dependency 'pry', '~> 0.10'
|
28
|
+
spec.add_development_dependency 'rb-readline', '~> 0.4.2'
|
29
|
+
|
30
|
+
spec.add_runtime_dependency 'activesupport', '>= 4.0.0'
|
31
|
+
end
|