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.
- 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
|