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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Twobook
2
+ VERSION = '0.1.1'
3
+ 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