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,322 @@
1
+ require 'forwardable'
2
+
3
+ module BillingLogic::Strategies
4
+ # Because this is no longer the behavior in Ruby 2(.3?)
5
+ class ToStringArray < Array
6
+ def to_s
7
+ "[#{map { |child| child.to_s }.join(', ')}]"
8
+ end
9
+ end
10
+ # The BaseStrategy defines generic functions used by various BillingLogic::Strategies.
11
+ class IndependentPaymentStrategy
12
+
13
+ attr_accessor :desired_state, :current_state, :payment_command_builder_class, :default_command_builder
14
+
15
+ def initialize(opts = {})
16
+ @current_state = opts.delete(:current_state) || []
17
+ @desired_state = opts.delete(:desired_state) || []
18
+ @command_list = ToStringArray.new
19
+ @payment_command_builder_class = opts.delete(:payment_command_builder_class) || default_command_builder
20
+ end
21
+
22
+ # Returns a string representing the commands the Strategy generates
23
+ #
24
+ # @return [String] the string representation of the commands the Strategy generates
25
+ def command_list
26
+ calculate_list
27
+ @command_list.flatten
28
+ end
29
+
30
+ # Returns BillingEngine::Client::Products to be added, grouped by date
31
+ #
32
+ def products_to_be_added_grouped_by_date
33
+ group_by_date(products_to_be_added)
34
+ end
35
+
36
+ # Returns an array of BillingEngine::Client::Products to be added because they're desired but not active
37
+ #
38
+ # @return [Array<BillingEngine::Client::Product>] array of desired but inactive BillingEngine::Client::Products scheduled to be added
39
+ def products_to_be_added
40
+ desired_state.reject do |product|
41
+ ProductComparator.new(product).in_like?(active_products)
42
+ end
43
+ end
44
+
45
+ # Returns an array of BillingEngine::Client::Products to be removed because they're active but not desired
46
+ #
47
+ # @return [Array<BillingEngine::Client::Product>] array of active but no longer desired BillingEngine::Client::Products scheduled for removal
48
+ def products_to_be_removed
49
+ active_products.reject do |product|
50
+ ProductComparator.new(product).included?(desired_state)
51
+ end
52
+ end
53
+
54
+ # Returns an array of inactive BillingEngine::Client::Products in the CurrentState
55
+ #
56
+ # @return [Array<BillingEngine::Client::Product>] array of inactive BillingEngine::Client::Products in the CurrentState
57
+ def inactive_products
58
+ neither_active_nor_pending_profiles.map { |profile| profile.products }.flatten
59
+ end
60
+
61
+ # Returns an array of PaymentProfiles with profile_status 'ActiveProfile' or 'PendingProfile'
62
+ #
63
+ # @return [Array<PaymentProfile>] array of PaymentProfiles in the CurrentState with profile_status 'ActiveProfile' or 'PendingProfile'
64
+ def active_profiles
65
+ active_or_pending_profiles
66
+ end
67
+
68
+ # Returns an array of active BillingEngine::Client::Products from the CurrentState
69
+ #
70
+ # @return [Array<BillingEngine::Client::Product>] array of active BillingEngine::Client::Products in the CurrentState
71
+ def active_products
72
+ current_state.active_products
73
+ end
74
+
75
+ # CurrentState PaymentProfiles with payment_profile of 'ActiveProfile' or 'PendingProfile'
76
+ #
77
+ # @return [Array<PaymentProfile>] array of all 'ActiveProfile' or 'PendingProfile' PaymentProfiles
78
+ # for the CurrentState
79
+ def active_or_pending_profiles
80
+ current_state.reject { |profile| !profile.active_or_pending? }
81
+ end
82
+
83
+ # CurrentState PaymentProfiles with payment_profile of neither 'ActiveProfile' nor 'PendingProfile' (i.e., either
84
+ # 'CancelledProfile' or 'ComplimentaryProfile')
85
+ #
86
+ # @return [Array<PaymentProfile>] array of all PaymentProfiles for the CurrentState with payment_profile of neither
87
+ # 'ActiveProfile' nor 'PendingProfile'
88
+ def neither_active_nor_pending_profiles
89
+ current_state.reject { |profile| profile.active_or_pending? }
90
+ end
91
+
92
+ # @deprecated Too confusing. Please directly call #active_or_pending_profiles or #neither_active_nor_pending_profiles
93
+ def profiles_by_status(active_or_pending = nil)
94
+ active_or_pending ? active_or_pending_profiles : neither_active_nor_pending_profiles
95
+ end
96
+
97
+ protected
98
+
99
+ # Declares IndependentPaymentStrategy's #default_command_builder to be
100
+ # BillingLogic::CommandBuilders::WordBuilder
101
+ # @return [BillingLogic::CommandBuilders::WordBuilder]
102
+ def default_command_builder
103
+ BillingLogic::CommandBuilders::WordBuilder
104
+ end
105
+
106
+ def removed_obsolete_subscriptions(subscriptions)
107
+ [subscriptions.select{ |sub| sub.active_or_pending? } + subscriptions.reject { |sub| !sub.paid_until_date || sub.paid_until_date < today } ].flatten.compact.uniq
108
+ end
109
+
110
+ def calculate_list
111
+ reset_command_list!
112
+ add_commands_for_products_to_be_removed!
113
+ add_commands_for_products_to_be_added!
114
+ end
115
+
116
+ # adds recurring payment command (strings) to @command_list for the
117
+ # group_of_products and date passed as block parameters
118
+ def add_commands_for_products_to_be_added!
119
+ with_products_to_be_added do |group_of_products, date|
120
+ group_of_products.each do |products|
121
+ @command_list << create_recurring_payment_command([products], :paid_until_date => date)
122
+ end
123
+ end
124
+ end
125
+
126
+ # this doesn't feel like it should be here
127
+ def group_by_date(new_products)
128
+ group = {}
129
+ new_products.each do |product|
130
+ date = nil
131
+ if previously_cancelled_product?(product)
132
+ date = next_payment_date_from_profile_with_product(product, :active => false)
133
+ elsif (previous_product = changed_product_subscription?(product))
134
+ unless previous_product_was_disabled?(product, previous_product)
135
+ update_product_billing_cycle_and_payment!(product, previous_product)
136
+ date = next_payment_date_from_product(product, previous_product)
137
+ end
138
+ end
139
+ date = (date.nil? || date < today) ? today : date
140
+ group[date] ||= []
141
+ group[date] << product
142
+ end
143
+ group.map { |k, v| [v, k] }
144
+ end
145
+
146
+ def previously_cancelled_product?(product)
147
+ inactive_products.detect do |inactive_product|
148
+ ProductComparator.new(inactive_product).same_class?(product)
149
+ end
150
+ end
151
+
152
+ def changed_product_subscription?(product)
153
+ products_to_be_removed.detect do |removed_product|
154
+ ProductComparator.new(removed_product).same_class?(product)
155
+ end
156
+ end
157
+
158
+ def next_payment_date_from_profile_with_product(product, opts = {:active => false})
159
+ (opts[:active] ? active_or_pending_profiles : neither_active_nor_pending_profiles).map do |profile|
160
+ profile.paid_until_date if ProductComparator.new(product).in_class_of?(profile.products)
161
+ end.compact.max
162
+ end
163
+
164
+ def update_product_billing_cycle_and_payment!(product, previous_product)
165
+ if product.billing_cycle.periodicity > previous_product.billing_cycle.periodicity
166
+ product.billing_cycle.anniversary = previous_product.billing_cycle.anniversary
167
+ end
168
+ end
169
+
170
+ def next_payment_date_from_product(product, previous_product)
171
+ if product.billing_cycle.periodicity > previous_product.billing_cycle.periodicity
172
+ product.billing_cycle.anniversary
173
+ else
174
+ product.billing_cycle.anniversary = next_payment_date_from_profile_with_product(product, :active => true)
175
+ end
176
+ end
177
+
178
+ def previous_product_was_disabled?(product, previous_product)
179
+ @command_list.compact.detect do |command|
180
+ command.products.any? {|product| product.identifier == previous_product.identifier } && command.disable
181
+ end
182
+ end
183
+
184
+ # for easy stubbing/subclassing/replacement
185
+ def today
186
+ Date.current
187
+ end
188
+
189
+ public
190
+ # this should be part of a separate strategy object
191
+ def add_commands_for_products_to_be_removed!
192
+ active_profiles.each do |profile|
193
+
194
+ # We need to issue refunds before cancelling profiles
195
+ refund_options = issue_refunds_if_necessary(profile)
196
+ remaining_products = remaining_products_after_product_removal_from_profile(profile)
197
+
198
+ if remaining_products.empty? # all products in payment profile needs to be removed
199
+
200
+ @command_list << cancel_recurring_payment_command(profile, refund_options)
201
+
202
+ elsif remaining_products.size == profile.products.size # nothing has changed
203
+ #
204
+ # do nothing
205
+ #
206
+ else # only some products are being removed and the profile needs to be updated
207
+
208
+ if remaining_products.size >= 1
209
+
210
+ @command_list << remove_product_from_payment_profile(profile.identifier,
211
+ removed_products_from_profile(profile),
212
+ refund_options)
213
+ else
214
+
215
+ @command_list << cancel_recurring_payment_command(profile, refund_options)
216
+ @command_list << create_recurring_payment_command(remaining_products,
217
+ :paid_until_date => profile.paid_until_date,
218
+ :period => extract_period_from_product_list(remaining_products))
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ def extract_period_from_product_list(products)
225
+ products.first.billing_cycle.period
226
+ end
227
+
228
+ def remaining_products_after_product_removal_from_profile(profile)
229
+ profile.active_products.reject { |product| products_to_be_removed.include?(product) }
230
+ end
231
+
232
+ def removed_products_from_profile(profile)
233
+ profile.products.select { |product| products_to_be_removed.include?(product) }
234
+ end
235
+
236
+ def issue_refunds_if_necessary(profile)
237
+ ret = {}
238
+ if !profile.refundable_payment_amount(removed_products_from_profile(profile)).zero?
239
+ ret.merge!(refund_recurring_payments_command(profile.identifier, profile.refundable_payment_amount(removed_products_from_profile(profile))))
240
+ ret.merge!(disable_subscription(profile.identifier))
241
+ elsif ((Date.current - 1) <= profile.billing_start_date) # && (Date.current >= profile.billing_start_date)
242
+ ret.merge!(disable_subscription(profile.identifier))
243
+ end
244
+ ret
245
+ end
246
+
247
+ def refund_recurring_payments_command(profile_id, amount)
248
+ { :refund => amount, :profile_id => profile_id }
249
+ end
250
+
251
+ def disable_subscription(profile_id)
252
+ { :disable => true }
253
+ end
254
+
255
+ # these messages seems like they should be pluggable
256
+ def cancel_recurring_payment_command(profile, opts = {})
257
+ payment_command_builder_class.cancel_recurring_payment_commands(profile, opts)
258
+ end
259
+
260
+ def remove_product_from_payment_profile(profile_id, removed_products, opts = {})
261
+ payment_command_builder_class.remove_product_from_payment_profile(profile_id, removed_products, opts)
262
+ end
263
+
264
+ def create_recurring_payment_command(products, opts = {:paid_until_date => Date.current})
265
+ payment_command_builder_class.create_recurring_payment_commands(products, opts)
266
+ end
267
+
268
+ def with_products_to_be_added(&block)
269
+ unless (products_to_be_added = products_to_be_added_grouped_by_date).empty?
270
+ products_to_be_added.each do |group_of_products, date|
271
+ yield(group_of_products, date)
272
+ end
273
+ end
274
+ end
275
+
276
+ class ProductComparator
277
+ extend Forwardable
278
+ def_delegators :@product, :name, :price, :billing_cycle
279
+ def initialize(product)
280
+ @product = product
281
+ end
282
+
283
+ def included?(product_list)
284
+ product_list.any? { |product| ProductComparator.new(product).similar?(self) }
285
+ end
286
+
287
+ def in_class_of?(product_list)
288
+ product_list.any? { |product| ProductComparator.new(product).same_class?(self) }
289
+ end
290
+
291
+ def in_like?(product_list)
292
+ product_list.any? { |product| ProductComparator.new(product).like?(self) }
293
+ end
294
+
295
+ def like?(other_product)
296
+ similar?(other_product) && same_periodicity?(other_product)
297
+ end
298
+
299
+ def similar?(other_product)
300
+ same_class?(other_product) && same_price?(other_product)
301
+ end
302
+
303
+ def same_periodicity?(other_product)
304
+ @product.billing_cycle.periodicity == other_product.billing_cycle.periodicity
305
+ end
306
+
307
+ def same_class?(other_product)
308
+ @product.name == other_product.name
309
+ end
310
+
311
+ def same_price?(other_product)
312
+ BigDecimal(@product.price.to_s) == BigDecimal(other_product.price.to_s)
313
+ end
314
+
315
+ end
316
+
317
+ def reset_command_list!
318
+ @command_list.clear
319
+ end
320
+
321
+ end
322
+ end
@@ -0,0 +1,3 @@
1
+ module BillingLogic
2
+ VERSION = "0.0.3"
3
+ end
@@ -0,0 +1,23 @@
1
+ require 'bigdecimal'
2
+ require "billing_logic/version"
3
+ require 'billing_logic/current_state'
4
+ require 'billing_logic/billing_cycle'
5
+ require 'billing_logic/proration_calculator'
6
+ require 'billing_logic/payment_command_builder'
7
+ require 'billing_logic/command_builders/command_builders'
8
+ require 'active_support/core_ext/date/calculations'
9
+ module BillingLogic
10
+ module CommandBuilders
11
+ autoload :BuilderHelpers, 'billing_logic/command_builders/command_builders'
12
+ autoload :BasicBuilder, 'billing_logic/command_builders/command_builders'
13
+ autoload :WordBuilder , 'billing_logic/command_builders/command_builders'
14
+ autoload :AggregateWordBuilder, 'billing_logic/command_builders/command_builders'
15
+ autoload :ActionObject, 'billing_logic/command_builders/command_builders'
16
+ autoload :SINGLE_PRODUCT_REGEX, 'billing_logic/command_builders/command_builders'
17
+ autoload :CurrentState, 'billing_logic/current_state'
18
+
19
+ end
20
+ module Strategies
21
+ autoload :IndependentPaymentStrategy, 'billing_logic/strategies/independent_payment_strategy'
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ require 'spec_helper'
2
+
3
+ describe BillingLogic::BillingCycle do
4
+ context "a billing cycle" do
5
+ before do
6
+ @cycle_45_days = BillingLogic::BillingCycle.new(:period => :day, :frequency => 45)
7
+ @one_day_cycle = BillingLogic::BillingCycle.new(:period => :day, :frequency => 1)
8
+ @one_week_cycle = BillingLogic::BillingCycle.new(:period => :week, :frequency => 1)
9
+ @semimonth_cycle = BillingLogic::BillingCycle.new(:period => :semimonth, :frequency => 1)
10
+ @one_month_cycle = BillingLogic::BillingCycle.new(:period => :month, :frequency => 1)
11
+ @one_year_cycle = BillingLogic::BillingCycle.new(:period => :year, :frequency => 1)
12
+ end
13
+
14
+ it "should know about its period type" do
15
+ @cycle_45_days.period.should == :day
16
+ end
17
+
18
+ it "should know about its frequency" do
19
+ @cycle_45_days.frequency.should == 45
20
+ end
21
+
22
+ it "should be able to calculate its periodicity" do
23
+ @cycle_45_days.periodicity.should == 45
24
+ @one_year_cycle.periodicity.should == 365
25
+ end
26
+
27
+ it "should know how to compare itself" do
28
+ @one_day_cycle.should < @one_week_cycle
29
+ @one_week_cycle.should < @semimonth_cycle
30
+ @semimonth_cycle.should < @one_month_cycle
31
+ @one_month_cycle.should < @cycle_45_days
32
+ @cycle_45_days.should < @one_year_cycle
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ describe BillingLogic::CommandBuilders::ActionObject do
4
+
5
+ before do
6
+ Time.zone = "Eastern Time (US & Canada)"
7
+ end
8
+
9
+ it "removes two products from a bundle with one payment profile" do
10
+ command = "remove (B @ $20/mo & C @ $20/mo) from [(A @ $30/mo & B @ $20/mo & C @ $20/mo) @ $70/mo] now"
11
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == command
12
+ end
13
+
14
+ it "adds two new products" do
15
+ command = "add (B @ $20/mo & C @ $20/mo) @ $40.00/mo now"
16
+ translated_command = command.gsub("now", "on ") + Time.zone.now.strftime('%m/%d/%y')
17
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == translated_command
18
+ end
19
+
20
+ it "cancels a product" do
21
+ command = "cancel [A @ $30/mo] now"
22
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == command
23
+ end
24
+
25
+ it "cancels a product that is also a bundle (indicated by parentheses)" do
26
+ command = "cancel [(A @ $30/mo) @ $30/mo] now"
27
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == command
28
+ end
29
+
30
+ it "adds a product on a future date" do
31
+ command = "add (B @ $300/yr) @ $300.00/yr on 03/10/12"
32
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == command
33
+ end
34
+
35
+ it "cancels and disables a product" do
36
+ command = "cancel and disable [(A @ $30/mo & B @ $40/mo & C @ $25/mo) @ $95/mo] with refund $30.00 now"
37
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == command
38
+ end
39
+
40
+ it "expects you to pass in 'from [...]' when removing a product" do
41
+ command = "remove (B @ $20/mo & C @ $20/mo) now"
42
+ translated_command = command.sub("now","from [] now")
43
+ BillingLogic::CommandBuilders::ActionObject.from_string(command).to_s.should == translated_command
44
+ end
45
+
46
+ end
47
+
@@ -0,0 +1,234 @@
1
+ require 'spec_helper'
2
+
3
+ module BillingLogic
4
+
5
+ # an IndependentPaymentStrategy gives each product its own payment_profile
6
+ describe Strategies::IndependentPaymentStrategy do
7
+ module With0RefundablePayment
8
+ def refundable_payment_amount(foo)
9
+ 0.0
10
+ end
11
+ end
12
+ let(:monthly_cycle) do
13
+ BillingCycle.new(:period => :month,
14
+ :frequency => 1,
15
+ :anniversary => Date.current - 7)
16
+ end
17
+
18
+ let(:yearly_cycle) do
19
+ BillingCycle.new(:period => :year,
20
+ :frequency => 1,
21
+ :anniversary => Date.current - 7)
22
+ end
23
+
24
+ let(:product_a) { MockProduct.new(:identifier => 1, :name => 'A', :price => 10, :billing_cycle => monthly_cycle, :initial_payment => 0.0) }
25
+ let(:product_a_yearly) { MockProduct.new(:identifier => 1, :name => 'A', :price => 90, :billing_cycle => yearly_cycle, :initial_payment => 0.0) }
26
+ let(:product_b) { MockProduct.new(:identifier => 2, :name => 'B', :price => 20, :billing_cycle => monthly_cycle, :initial_payment => 0.0) }
27
+ let(:product_c) { MockProduct.new(:identifier => 3, :name => 'C', :price => 30, :billing_cycle => monthly_cycle, :initial_payment => 0.0) }
28
+ let(:product_d) { MockProduct.new(:identifier => 4, :name => 'D', :price => 40, :billing_cycle => monthly_cycle, :initial_payment => 0.0) }
29
+ let(:strategy) { Strategies::IndependentPaymentStrategy.new(:current_state => ::BillingLogic::CurrentState.new([])) }
30
+
31
+
32
+ let(:profile_a) do
33
+ MockProfile.new(
34
+ :products => [product_a, product_b],
35
+ :current_products => [product_a, product_b],
36
+ :active_products => [product_a, product_b],
37
+ :price => 30,
38
+ :identifier => 'i-1',
39
+ :billing_start_date => monthly_cycle.anniversary,
40
+ :paid_until_date => monthly_cycle.next_payment_date,
41
+ :active_or_pending => true
42
+ )
43
+ end
44
+
45
+ let(:canceled_profile_d) do
46
+ MockProfile.new(
47
+ :products => [product_d],
48
+ :current_products => [product_d],
49
+ :active_products => [],
50
+ :price => 40,
51
+ :identifier => 'i-4',
52
+ :billing_start_date => monthly_cycle.anniversary,
53
+ :paid_until_date => monthly_cycle.next_payment_date,
54
+ :active_or_pending => false,
55
+ )
56
+ end
57
+
58
+ let(:strategy_with_3_active_products) do
59
+ profile = MockProfile.new(
60
+ :products => [product_c],
61
+ :current_products => [product_c],
62
+ :active_products => [product_c],
63
+ :price => 30,
64
+ :identifier => 'i-2',
65
+ :billing_start_date => monthly_cycle.anniversary,
66
+ :paid_until_date => monthly_cycle.next_payment_date,
67
+ :active_or_pending => true,
68
+ )
69
+ Strategies::IndependentPaymentStrategy.new(:current_state => ::BillingLogic::CurrentState.new([profile_a, profile]))
70
+ end
71
+
72
+ describe "#active_products" do
73
+ it "should return an array" do
74
+ strategy.active_products.should be_kind_of(Array)
75
+ end
76
+
77
+ it "should return the products in the current state" do
78
+ strategy_with_3_active_products.active_products.should == [product_a, product_b, product_c]
79
+ end
80
+ end
81
+
82
+ describe "#products_to_be_added" do
83
+ it "should not return products that are already in the current state" do
84
+ strategy.current_state = ::BillingLogic::CurrentState.new([])
85
+ strategy.desired_state = []
86
+ strategy.products_to_be_added.should be_empty
87
+ end
88
+
89
+ it "should return products that are not in the current state" do
90
+ strategy.current_state = ::BillingLogic::CurrentState.new([])
91
+ strategy.desired_state = [product_a]
92
+ strategy.products_to_be_added.should == [product_a]
93
+ end
94
+ end
95
+
96
+ describe "#products_to_be_added_grouped_by_date" do
97
+ it "should return products that are not in the current state" do
98
+ strategy.current_state = ::BillingLogic::CurrentState.new([])
99
+ strategy.desired_state = [product_a]
100
+ strategy.products_to_be_added_grouped_by_date.should == [[[product_a], Date.current]]
101
+ end
102
+ end
103
+
104
+ context 'with empty current state' do
105
+ let(:strategy) { Strategies::IndependentPaymentStrategy.new(:current_state => ::BillingLogic::CurrentState.new([])) }
106
+
107
+ context 'with an empty desired state' do
108
+ it 'should return an empty command list' do
109
+ strategy.command_list.should == []
110
+ end
111
+ end
112
+
113
+ context 'with 1 new subscription in the desired state' do
114
+ before do
115
+ strategy.desired_state = [product_a]
116
+ end
117
+
118
+ it "should call create_recurring_payment_command with 1 product on the command builder object" do
119
+ strategy.payment_command_builder_class.should_receive(:create_recurring_payment_commands).with([product_a], :paid_until_date => Date.current).once
120
+ strategy.command_list
121
+ end
122
+
123
+ it 'should return 1 command in the command list' do
124
+ strategy.command_list.size.should == 1
125
+ end
126
+
127
+ end
128
+
129
+ end
130
+
131
+ context 'with 2 current profile with 3 products' do
132
+ context "when going towards a desired state of no products" do
133
+ before do
134
+ strategy_with_3_active_products.desired_state = []
135
+ end
136
+
137
+ # NOTE: this should be moved to a separate class
138
+ it 'should know which product should be removed' do
139
+ strategy_with_3_active_products.products_to_be_removed.should == [product_a, product_b, product_c]
140
+ end
141
+
142
+ it "should call :cancel_recurring_payment_command twice" do
143
+ strategy_with_3_active_products.should_receive(:cancel_recurring_payment_command).twice
144
+ strategy_with_3_active_products.command_list
145
+ end
146
+ end
147
+
148
+ context "when removing a partial product from a profile" do
149
+ before do
150
+ strategy_with_3_active_products.desired_state = [product_b, product_c]
151
+ end
152
+
153
+ it "should remove the product from the profile with the partial match" do
154
+ strategy_with_3_active_products.should_receive(:remove_product_from_payment_profile).with('i-1', [product_a], {}).once
155
+ strategy_with_3_active_products.command_list
156
+ end
157
+
158
+ end
159
+
160
+ context "with a yearly subscription" do
161
+ before do
162
+ profile_a.products = [product_a_yearly]
163
+ profile_a.active_products = profile_a.products
164
+ profile_a.current_products = profile_a.products
165
+ profile_a.paid_until_date = product_a_yearly.billing_cycle.next_payment_date
166
+ profile_a.billing_cycle = yearly_cycle
167
+ strategy.current_state = ::BillingLogic::CurrentState.new([profile_a])
168
+ end
169
+
170
+ it "should add monthly plan at the end of the year when switching to monthly cycle" do
171
+ strategy.desired_state = [product_a]
172
+ strategy.should_receive(:create_recurring_payment_command).with([product_a], hash_including(:paid_until_date => profile_a.paid_until_date)).once
173
+ strategy.should_receive(:cancel_recurring_payment_command).with(profile_a, {}).once
174
+ strategy.command_list
175
+ # Note: I used the following for debugging, but leaving it inside
176
+ # would couple the tests for the strategy with the command builder as
177
+ # well, which would be bad.
178
+ # .should == ["add 1 @ $10/mo on #{profile_a.next_payment_date.strftime('%m/%d/%y')}", "cancel i-1 now"]
179
+ end
180
+
181
+ context "that is cancelled and being re-added" do
182
+ before do
183
+ profile_a.active_or_pending = false
184
+ profile_a.active_products = []
185
+ profile_a.current_products = []
186
+ strategy.desired_state = [product_a]
187
+ end
188
+
189
+ it "shouldn't try to cancel the yearly subscription if already cancelled" do
190
+ strategy.should_not_receive(:cancel_recurring_payment_command)
191
+ strategy.command_list
192
+ end
193
+
194
+ it "should re-add the cancelled product at the end of the year" do
195
+ strategy.should_receive(:create_recurring_payment_command).with([product_a], :paid_until_date => profile_a.paid_until_date).once
196
+ strategy.command_list
197
+ end
198
+ end
199
+ end
200
+
201
+ context "with one of them cancelled" do
202
+ before do
203
+ state = ::BillingLogic::CurrentState.new([canceled_profile_d])
204
+ strategy.current_state = state
205
+ end
206
+ context "when re-adding the cancelled product" do
207
+ it "should add it to the end of the cancelled period" do
208
+ strategy.desired_state = [product_d]
209
+ strategy.should_receive(:create_recurring_payment_command).with([product_d], :paid_until_date => canceled_profile_d.paid_until_date).once
210
+ strategy.command_list
211
+ end
212
+ end
213
+
214
+ end
215
+
216
+ context "with one of them cancelled twice" do
217
+ before do
218
+ state = ::BillingLogic::CurrentState.new([canceled_profile_d, canceled_profile_d.clone])
219
+ strategy.current_state = state
220
+ end
221
+
222
+ context "when re-adding the cancelled product" do
223
+ it "should add it to the end of the cancelled period" do
224
+ strategy.desired_state = [product_d]
225
+ strategy.should_receive(:create_recurring_payment_command).with([product_d], :paid_until_date => canceled_profile_d.paid_until_date).once
226
+ strategy.command_list
227
+ end
228
+ end
229
+
230
+ end
231
+
232
+ end
233
+ end
234
+ end