billing_logic 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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