billing_logic 0.0.3

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.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/README.rdoc +89 -0
  3. data/config/cruise/run_cruise +10 -0
  4. data/features/change_periodicity_on_anniversary_day.feature +34 -0
  5. data/features/change_periodicity_on_signup_day.feature +34 -0
  6. data/features/independent_payment_strategy.feature +93 -0
  7. data/features/same_day_cancellation_policy.feature +57 -0
  8. data/features/step_definitions/cancellation_steps.rb +52 -0
  9. data/features/step_definitions/implementation_specific_steps.rb +41 -0
  10. data/features/step_definitions/independent_payment_strategy_steps.rb +4 -0
  11. data/features/step_definitions/steps.rb +15 -0
  12. data/features/support/env.rb +13 -0
  13. data/features/support/helpers.rb +73 -0
  14. data/features/support/parameter_types.rb +42 -0
  15. data/lib/billing_logic/billing_cycle.rb +77 -0
  16. data/lib/billing_logic/command_builders/command_builders.rb +179 -0
  17. data/lib/billing_logic/current_state.rb +18 -0
  18. data/lib/billing_logic/current_state_mixin.rb +32 -0
  19. data/lib/billing_logic/payment_command_builder.rb +54 -0
  20. data/lib/billing_logic/proration_calculator.rb +25 -0
  21. data/lib/billing_logic/strategies/independent_payment_strategy.rb +322 -0
  22. data/lib/billing_logic/version.rb +3 -0
  23. data/lib/billing_logic.rb +23 -0
  24. data/spec/billing_info/billing_cycle_spec.rb +35 -0
  25. data/spec/billing_info/command_builders/command_builder_spec.rb +47 -0
  26. data/spec/billing_info/independent_payment_strategy_spec.rb +234 -0
  27. data/spec/billing_info/payment_command_builder_spec.rb +41 -0
  28. data/spec/billing_info/proration_calculator_spec.rb +65 -0
  29. data/spec/spec_helper.rb +61 -0
  30. metadata +227 -0
@@ -0,0 +1,77 @@
1
+ module BillingLogic
2
+ class BillingCycle
3
+ include Comparable
4
+ attr_accessor :period, :frequency, :anniversary
5
+ TIME_UNITS = { :day => 1, :week => 7, :month => 365/12.0, :semimonth=> 365/24, :year => 365 }
6
+
7
+ # Creates a new BillingCycle instance
8
+ #
9
+ # @param opts [Hash] holds :period, :frequency, and :anniversary
10
+ # @return [BillingCycle] a billing cycle with .period, .frequency and .anniversary
11
+ def initialize(opts = {})
12
+ self.period = opts[:period]
13
+ self.frequency = opts[:frequency] || 1
14
+ self.anniversary = opts[:anniversary]
15
+ end
16
+
17
+ # Compares self against another BillingCycle instance by #periodicity
18
+ #
19
+ # @param other [BillingCycle] another BillingCycle instance
20
+ # @return [-1, 0, 1] integer determined by which BillingCycle is longer
21
+ def <=>(other)
22
+ self.periodicity <=> other.periodicity
23
+ end
24
+
25
+ def periodicity
26
+ time_unit_measure * frequency
27
+ end
28
+
29
+ def days_in_billing_cycle_including(date)
30
+ (closest_anniversary_date_including(date) - anniversary).abs
31
+ end
32
+
33
+ # Date on which the next payment is due and scheduled to be paid
34
+ # anniversary will always equal date
35
+ def next_payment_date
36
+ closest_future_anniversary_date_including(anniversary)
37
+ end
38
+
39
+ # Used for prorationing in the single payment strategy
40
+ # Not currently in use
41
+ def closest_anniversary_date_including(date)
42
+ date_in_past = date < anniversary
43
+ advance_date_by_period(anniversary, date_in_past)
44
+ end
45
+
46
+ def closest_future_anniversary_date_including(date)
47
+ return anniversary if anniversary > date
48
+ return advance_date_by_period(anniversary.dup) if anniversary == date
49
+ next_anniversary = anniversary.dup
50
+ while(date > next_anniversary)
51
+ next_anniversary = advance_date_by_period(next_anniversary)
52
+ end
53
+ next_anniversary
54
+ end
55
+
56
+ def advance_date_by_period(date, revert = false)
57
+ operators = {:month => revert ? :<< : :>>,
58
+ :day => revert ? :- : :+ }
59
+ case self.period
60
+ when :year
61
+ date.send(operators[:month], (self.frequency * 12))
62
+ when :month
63
+ date.send(operators[:month], self.frequency)
64
+ when :week
65
+ date.send(operators[:month], (self.frequency * 7))
66
+ when :day
67
+ date.send(operators[:month], self.frequency)
68
+ end
69
+ end
70
+
71
+ private
72
+ def time_unit_measure
73
+ TIME_UNITS[self.period]
74
+ end
75
+
76
+ end
77
+ end
@@ -0,0 +1,179 @@
1
+ module BillingLogic
2
+ module CommandBuilders
3
+ SINGLE_PRODUCT_REGEX = '(\w+) @ \$([\d\.]+)(\/mo|\/yr)?'
4
+ DATE_REGEX = '(\d{1,2}\/\d{1,2}\/\d{2,4})'
5
+ MONEY_REGEX = '\$([\d\.]+)'
6
+
7
+ module BuilderHelpers
8
+ def self.money(money)
9
+ "%01.2f" % Float(money)
10
+ end
11
+ end
12
+
13
+ class ProductList
14
+ # ProductList.parse returns a dumb array
15
+ # You can't add behavior (I tried adding #price)
16
+ def self.parse(string, options = {})
17
+ if (products = (string =~ /\(([^\(\)]*)\)/) ? $1 : nil)
18
+ products.split(/ & /).map do |product_string|
19
+ ProductStub.parse(product_string, options)
20
+ end
21
+ else
22
+ []
23
+ end
24
+ end
25
+ end
26
+
27
+ class ProductStub
28
+ attr_accessor :name, :price, :identifier, :billing_cycle, :payments, :initial_payment
29
+ def self.parse(string, options = {})
30
+ string =~ /#{BillingLogic::CommandBuilders::SINGLE_PRODUCT_REGEX}/
31
+ billing_cycle = $3 ? ::BillingLogic::BillingCycle.new(:frequency => 1, :period => $3.include?('mo') ? :month : :year) : nil
32
+ product = (options[:product_class] || self).new
33
+ product.name = $1
34
+ product.price = Float($2)
35
+ product.identifier = "#{$1} @ $#{$2}#{$3}"
36
+ product.billing_cycle = billing_cycle
37
+ product.initial_payment = options[:initial_payment] || 0
38
+ product
39
+ end
40
+
41
+ end
42
+
43
+ class ActionObject
44
+ DATE_FORMAT = '%m/%d/%y'
45
+
46
+ attr_accessor :action, :products, :profile_id, :initial_payment, :disable, :refund, :starts_on, :price
47
+
48
+ def initialize(opts = {})
49
+ @action = opts[:action]
50
+ @profile_id = opts[:profile_id]
51
+ @products = opts[:products]
52
+ @disable = opts[:disable]
53
+ @refund = opts[:refund]
54
+ @initial_payment = opts[:initial_payment]
55
+ @starts_on = opts[:starts_on]
56
+ @price = opts[:price]
57
+ end
58
+
59
+ def to_s
60
+ if [:cancel, :remove, :add, :add_bundle].include?(action)
61
+ send("#{action.to_s}_action_string")
62
+ end
63
+ end
64
+
65
+ def cancel_action_string
66
+ "cancel #{'and disable ' if disable}[#{profile_id}] #{"with refund $#{BuilderHelpers.money(refund)} " if refund}now"
67
+ end
68
+
69
+ def remove_action_string
70
+ "remove (#{products.map { |product| product.identifier }.join(" & ")}) from [#{profile_id}] #{"with refund $#{BuilderHelpers.money(refund)} " if refund}now"
71
+ end
72
+
73
+ def add_action_string
74
+ products.map do |product|
75
+ initial_payment_string = total_initial_payment.zero? ? '' : " with initial payment set to $#{BuilderHelpers.money(total_initial_payment)}"
76
+ "add (#{product.identifier}) on #{starts_on.strftime(DATE_FORMAT)}#{initial_payment_string}"
77
+ end.to_s
78
+ end
79
+
80
+ def add_bundle_action_string
81
+ product_ids = products.map { |product| product.identifier }.join(' & ')
82
+ price ||= products.inject(0){ |k, product| k += product.price; k }
83
+ initial_payment_string = total_initial_payment.zero? ? '' : " with initial payment set to $#{BuilderHelpers.money(total_initial_payment)}"
84
+ "add (#{product_ids}) @ $#{BuilderHelpers.money(price)}#{periodicity_abbrev(products.first.billing_cycle.period)} on #{starts_on.strftime(DATE_FORMAT)}#{initial_payment_string}"
85
+ end
86
+
87
+ def self.from_string(string, options = {:product_class => ProductStub})
88
+ opts = {}
89
+ opts[:action] = case string
90
+ when /^add \(.*\) @/
91
+ :add_bundle
92
+ when /^(cancel|remove|add)/
93
+ $1.to_sym
94
+ end
95
+ opts[:disable] = !!(string =~ /and disable/)
96
+ opts[:starts_on] = (string =~ /on #{BillingLogic::CommandBuilders::DATE_REGEX}/) ? Date.strptime($1, DATE_FORMAT) : (string =~ /now$/) ? Time.now : nil
97
+ opts[:products] = ProductList.parse(string, options)
98
+
99
+ opts[:profile_id] = case opts[:action]
100
+ when :cancel, :remove
101
+ string =~ /\[(.*)\]/ ? $1 : nil
102
+ end
103
+ opts[:refund] = (string =~ /with refund #{BillingLogic::CommandBuilders::MONEY_REGEX}/) ? Float($1) : nil
104
+ opts[:initial_payment] = (string =~ /with initial payment set to #{BillingLogic::CommandBuilders::MONEY_REGEX}/) ? Float($1) : nil
105
+ opts[:price] = (string =~ /add \(.*\) @ #{BillingLogic::CommandBuilders::MONEY_REGEX}/) ? Float($1) : nil
106
+
107
+ self.new(opts)
108
+ end
109
+
110
+ def total_initial_payment
111
+ @initial_payment ||= products.map { |product| product.initial_payment || 0 }.reduce(0) { |a, e| a + e }
112
+ end
113
+
114
+ protected
115
+ def periodicity_abbrev(period)
116
+ case period
117
+ when :year; '/yr'
118
+ when :month;'/mo'
119
+ when :week; '/wk'
120
+ when :day; '/day'
121
+ else
122
+ period
123
+ end
124
+ end
125
+ end
126
+
127
+ class BasicBuilder
128
+ class << self
129
+ def create_recurring_payment_commands(products, opts = {:paid_until_date => Date.current})
130
+ raise Exception.new('Implement me')
131
+ end
132
+
133
+ # override this with Time.zone if used with rails
134
+ def time
135
+ Time
136
+ end
137
+
138
+ def cancel_recurring_payment_commands(profile, opts = {})
139
+ ActionObject.new(opts.merge(:action => :cancel,
140
+ :profile_id => profile.identifier,
141
+ :products => profile.products,
142
+ :when => time.now))
143
+ end
144
+
145
+ def remove_product_from_payment_profile(profile_id, products, opts)
146
+ ActionObject.new(opts.merge(:action => :remove,
147
+ :products => products,
148
+ :profile_id => profile_id,
149
+ :when => time.now))
150
+ end
151
+ end
152
+ end
153
+
154
+ class WordBuilder < BasicBuilder
155
+ class << self
156
+ def create_recurring_payment_commands(products, opts = {:paid_until_date => Date.current})
157
+ ActionObject.new(opts.merge(:action => :add,
158
+ :products => products,
159
+ :starts_on => opts[:paid_until_date],
160
+ :when => time.now))
161
+ end
162
+ end
163
+ end
164
+
165
+ class AggregateWordBuilder < BasicBuilder
166
+ class << self
167
+ include CommandBuilders::BuilderHelpers
168
+ def create_recurring_payment_commands(products, opts = {:paid_until_date => Date.current, :price => nil, :frequency => 1, :period => nil})
169
+ ActionObject.new(opts.merge(:action => :add_bundle,
170
+ :products => products,
171
+ :starts_on => opts[:paid_until_date],
172
+ :when => time.now))
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ end
179
+
@@ -0,0 +1,18 @@
1
+ require 'billing_logic/current_state_mixin'
2
+ module BillingLogic
3
+
4
+ # Holds the array of current PaymentProfiles
5
+ class CurrentState
6
+
7
+ include BillingLogic::CurrentStateMixin
8
+
9
+ # Initializes a CurrentState object holding an array of current PaymentProfiles
10
+ #
11
+ # @return [CurrentState] the CurrentState object holding an array of current PaymentProfiles
12
+ def initialize(profiles)
13
+ @profiles = profiles
14
+ end
15
+
16
+ end
17
+
18
+ end
@@ -0,0 +1,32 @@
1
+ module BillingLogic
2
+ module CurrentStateMixin
3
+ def self.included(clazz)
4
+ clazz.send(:attr_accessor, :profiles)
5
+ clazz.send(:include, Enumerable)
6
+ end
7
+
8
+ # Iterates through profiles, yielding each one-by-one to the block for processing
9
+ def each(&block)
10
+ profiles.each(&block)
11
+ end
12
+
13
+ # Returns array with only BillingEngine::Client::Products belonging to the CurrentState's current
14
+ # (i.e., currently paid-up) PaymentProfiles
15
+ #
16
+ # @return [Array] array of BillingEngine::Client::Products belonging to CurrentState's "current"
17
+ # PaymentProfiles (i.e., where paid_until_date >= Date.current )
18
+ def current_products
19
+ map { |profile| profile.current_products }.flatten
20
+ end
21
+
22
+ # Returns array with only BillingEngine::Client::Products belonging to the CurrentState's active
23
+ # (i.e., enabled & active/pending) PaymentProfiles
24
+ #
25
+ # @return [Array] array of BillingEngine::Client::Products belonging to CurrentState's
26
+ # "active" PaymentProfiles (i.e., where an 'active' PaymentProfile has #enabled => 1/true
27
+ # and #profile_status either 'active' or 'pending')
28
+ def active_products
29
+ map { |profile| profile.active_products }.flatten
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ module BillingLogic
2
+
3
+ # Converts an array of BillingEngine::Client::Products and a next_payment_date into (string) commands
4
+ # for creating recurring_payments. Also converts a list of PaymentProfile#ids into
5
+ # commands for canceling their recurring_payments.
6
+ class PaymentCommandBuilder
7
+
8
+ # Creates a PaymentCommandBuilder from an array of BillingEngine::Client::Products
9
+ #
10
+ # @param products [Array<BillingEngine::Client::Product>]
11
+ def initialize(products)
12
+ @products = products
13
+ end
14
+
15
+ # Groups BillingEngine::Client::Products into a hash with keys of BillingEngine::Client::Product#billing_cycle and values of the products
16
+ def group_products_by_billing_cycle
17
+ @products.inject({}) do |a, e|
18
+ a[e.billing_cycle] ||= []
19
+ a[e.billing_cycle] << e
20
+ a
21
+ end
22
+ end
23
+
24
+ class << self
25
+
26
+
27
+ # @return [String] the (string) command for creating recurring payments for
28
+ # the passed-in array of BillingEngine::Client::Products with the passed-in next_payment_date
29
+ def create_recurring_payment_commands(products, next_payment_date = Date.current)
30
+ self.new(products).group_products_by_billing_cycle.map do |k, prods|
31
+ {
32
+ :action => 'create_recurring_payment',
33
+ :products => prods,
34
+ :price => prods.inject(0) { |a, e| a + e.price; a },
35
+ :next_payment_date => next_payment_date,
36
+ :billing_cycle => k
37
+ }
38
+ end
39
+ end
40
+
41
+ # @return [String] the (string) command for canceling recurring payments for
42
+ # the passed-in array of PaymentProfile#ids
43
+ def cancel_recurring_payment_commands(*profile_ids)
44
+ profile_ids.map do |profile_id|
45
+ {
46
+ :action => :cancel_recurring_payment,
47
+ :payment_profile_id => profile_id
48
+ }
49
+ end
50
+ end
51
+ end
52
+
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ module BillingLogic
2
+ class ProrationCalculator
3
+ attr_accessor :billing_cycle, :price, :date
4
+ def initialize(opts = {})
5
+ self.billing_cycle = opts[:billing_cycle]
6
+ self.price = opts[:price]
7
+ self.date = opts[:date]
8
+ end
9
+
10
+ def prorate_from(date = self.date)
11
+ return price if date == self.billing_cycle.anniversary
12
+ average_daily_price_for_billing_cycle(date) * distance_from_date_in_days(date)
13
+ end
14
+ alias :prorate :prorate_from
15
+
16
+ def distance_from_date_in_days(date = self.date)
17
+ (date - self.billing_cycle.anniversary).abs
18
+ end
19
+
20
+ def average_daily_price_for_billing_cycle(date = self.date)
21
+ (self.price / (self.billing_cycle.days_in_billing_cycle_including(date)))
22
+ end
23
+
24
+ end
25
+ end