billing_logic 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.rdoc +89 -0
- data/config/cruise/run_cruise +10 -0
- data/features/change_periodicity_on_anniversary_day.feature +34 -0
- data/features/change_periodicity_on_signup_day.feature +34 -0
- data/features/independent_payment_strategy.feature +93 -0
- data/features/same_day_cancellation_policy.feature +57 -0
- data/features/step_definitions/cancellation_steps.rb +52 -0
- data/features/step_definitions/implementation_specific_steps.rb +41 -0
- data/features/step_definitions/independent_payment_strategy_steps.rb +4 -0
- data/features/step_definitions/steps.rb +15 -0
- data/features/support/env.rb +13 -0
- data/features/support/helpers.rb +73 -0
- data/features/support/parameter_types.rb +42 -0
- data/lib/billing_logic/billing_cycle.rb +77 -0
- data/lib/billing_logic/command_builders/command_builders.rb +179 -0
- data/lib/billing_logic/current_state.rb +18 -0
- data/lib/billing_logic/current_state_mixin.rb +32 -0
- data/lib/billing_logic/payment_command_builder.rb +54 -0
- data/lib/billing_logic/proration_calculator.rb +25 -0
- data/lib/billing_logic/strategies/independent_payment_strategy.rb +322 -0
- data/lib/billing_logic/version.rb +3 -0
- data/lib/billing_logic.rb +23 -0
- data/spec/billing_info/billing_cycle_spec.rb +35 -0
- data/spec/billing_info/command_builders/command_builder_spec.rb +47 -0
- data/spec/billing_info/independent_payment_strategy_spec.rb +234 -0
- data/spec/billing_info/payment_command_builder_spec.rb +41 -0
- data/spec/billing_info/proration_calculator_spec.rb +65 -0
- data/spec/spec_helper.rb +61 -0
- 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,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
|