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