relish-billing 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/.gitignore +8 -0
- data/.rspec +1 -0
- data/.rvmrc +1 -0
- data/Gemfile +4 -0
- data/Guardfile +9 -0
- data/README.md +14 -0
- data/Rakefile +7 -0
- data/features/cancel_subscription.feature +8 -0
- data/features/create_new_account.feature +17 -0
- data/features/see_account_status.feature +43 -0
- data/features/step_definitions/steps.rb +89 -0
- data/features/support/dsl.rb +113 -0
- data/features/support/headless.rb +11 -0
- data/features/support/recurly.rb +3 -0
- data/features/update_card_details.feature +15 -0
- data/features/upgrade_account.feature +10 -0
- data/lib/relish/billing.rb +38 -0
- data/lib/relish/billing/account.rb +37 -0
- data/lib/relish/billing/entities.rb +107 -0
- data/lib/relish/billing/fake_driver.rb +101 -0
- data/lib/relish/billing/recurly_api_driver.rb +340 -0
- data/lib/relish/billing/testing_helper.rb +27 -0
- data/lib/relish/billing/version.rb +5 -0
- data/relish-billing.gemspec +30 -0
- data/script/ci +17 -0
- data/spec/relish/billing/fake_driver_spec.rb +15 -0
- data/spec/relish/billing/recurly_api_driver_spec.rb +66 -0
- data/spec/relish/billing/user_profile_spec.rb +28 -0
- data/spec/support/driver_examples.rb +169 -0
- metadata +212 -0
@@ -0,0 +1,107 @@
|
|
1
|
+
module Relish
|
2
|
+
module Billing
|
3
|
+
|
4
|
+
class CardDetails
|
5
|
+
attr_reader :first_name, :last_name, :address1, :address2, :city, :state, :zip, :country, :number, :expiry_month, :expiry_year, :cvv
|
6
|
+
def initialize(params = {})
|
7
|
+
@first_name = params.fetch :first_name, ''
|
8
|
+
@last_name = params.fetch :last_name, ''
|
9
|
+
@address1 = params.fetch :address1, ''
|
10
|
+
@address2 = params.fetch :address2, ''
|
11
|
+
@city = params.fetch :city, ''
|
12
|
+
@state = params.fetch :state, ''
|
13
|
+
@zip = params.fetch :zip, ''
|
14
|
+
@country = params.fetch :country, ''
|
15
|
+
@number = params.fetch :number, ''
|
16
|
+
@expiry_month = params.fetch :expiry_month, ''
|
17
|
+
@expiry_year = params.fetch :expiry_year, ''
|
18
|
+
@cvv = params.fetch :cvv, ''
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class MaskedCardDetails
|
23
|
+
attr_reader :last_four
|
24
|
+
|
25
|
+
def initialize(params)
|
26
|
+
@last_four = params.fetch :last_four
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
require 'date'
|
31
|
+
class UserProfile
|
32
|
+
attr_reader :email, :id, :plan
|
33
|
+
def initialize(params)
|
34
|
+
@email = params.fetch :email
|
35
|
+
@id = params.fetch :id
|
36
|
+
@plan = params.fetch :plan
|
37
|
+
@trial_end_date = params.fetch :trial_end_date
|
38
|
+
end
|
39
|
+
|
40
|
+
def trial_end_date
|
41
|
+
realistic_trial_end_date
|
42
|
+
end
|
43
|
+
|
44
|
+
def with_plan(new_plan)
|
45
|
+
UserProfile.new(
|
46
|
+
email: email,
|
47
|
+
id: id,
|
48
|
+
plan: new_plan,
|
49
|
+
trial_end_date: trial_end_date)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def realistic_trial_end_date
|
55
|
+
return Date.today if @trial_end_date < Date.today
|
56
|
+
@trial_end_date
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class AccountStatus
|
61
|
+
attr_reader :plan_id, :card_details, :next_billing_date
|
62
|
+
def initialize(params)
|
63
|
+
@plan_id = params.fetch :plan_id
|
64
|
+
@card_details = params.fetch :card_details
|
65
|
+
@next_billing_date = params.fetch :next_billing_date
|
66
|
+
@non_renewing = params.fetch :non_renewing
|
67
|
+
@active = params.fetch :active
|
68
|
+
end
|
69
|
+
|
70
|
+
# If this flag is true, it means that the user has
|
71
|
+
# cancelled their subscription, but not yet hit the end
|
72
|
+
# of their billing period.
|
73
|
+
#
|
74
|
+
# So they can still access their account for the time being,
|
75
|
+
# but they should be told that their account will go
|
76
|
+
# back to free plan at the end of their billing cycle.
|
77
|
+
def non_renewing?
|
78
|
+
@non_renewing
|
79
|
+
end
|
80
|
+
|
81
|
+
# This this flag is true, it means the user has a
|
82
|
+
# paid account that's all up and running normally
|
83
|
+
def active?
|
84
|
+
@active
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
|
89
|
+
CardDeclinedError = Class.new(StandardError) do
|
90
|
+
attr_reader :reason
|
91
|
+
|
92
|
+
def initialize(message = nil)
|
93
|
+
@reason = Reason.new(message)
|
94
|
+
end
|
95
|
+
|
96
|
+
Reason = Struct.new(:message) do
|
97
|
+
def append_to(string)
|
98
|
+
string << message if message
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
InvalidPlanError = Class.new(StandardError)
|
104
|
+
AccountNotFoundError = Class.new(StandardError)
|
105
|
+
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require_relative 'entities'
|
2
|
+
|
3
|
+
module Relish
|
4
|
+
module Billing
|
5
|
+
class FakeDriver
|
6
|
+
def fixtures
|
7
|
+
default = { expiry_month: '', expiry_year: '', cvv: '', country: 'BE' }
|
8
|
+
@fixtures ||= {
|
9
|
+
valid_credit_card: CardDetails.new(default.merge(number: '1111111111111111')),
|
10
|
+
invalid_credit_card: CardDetails.new(default.merge(number: '*INVALIDINVALID*')),
|
11
|
+
other_valid_credit_card: CardDetails.new(default.merge(number: '1111222233334444')),
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def clear_test_data
|
16
|
+
@plans = nil
|
17
|
+
@cards = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def set_plan(profile)
|
21
|
+
ensure_account_for(profile)
|
22
|
+
plans[profile.id] = profile.plan
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
def save_card(profile, card)
|
27
|
+
ensure_account_for(profile)
|
28
|
+
raise CardDeclinedError if invalid?(card)
|
29
|
+
cards[profile.id] = card
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
def cancel_subscription(profile_id)
|
34
|
+
plans.delete(profile_id)
|
35
|
+
cards.delete(profile_id)
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def account_status(profile)
|
40
|
+
ensure_account_for(profile)
|
41
|
+
plan = plans.fetch(profile.id)
|
42
|
+
if active?(profile)
|
43
|
+
card_details = mask(cards[profile.id])
|
44
|
+
else
|
45
|
+
card_details = CardDetails.new
|
46
|
+
end
|
47
|
+
AccountStatus.new plan_id: plan,
|
48
|
+
card_details: card_details,
|
49
|
+
next_billing_date: next_billing_date(profile),
|
50
|
+
non_renewing: true,
|
51
|
+
active: active?(profile)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def next_billing_date(profile)
|
57
|
+
if active?(profile) && (profile.trial_end_date == Date.today)
|
58
|
+
Date.today.next_month
|
59
|
+
else
|
60
|
+
profile.trial_end_date
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def ensure_account_for(profile)
|
65
|
+
return if plans.key?(profile.id)
|
66
|
+
plans[profile.id] = profile.plan
|
67
|
+
end
|
68
|
+
|
69
|
+
def invalid?(card)
|
70
|
+
card == fixtures.fetch(:invalid_credit_card)
|
71
|
+
end
|
72
|
+
|
73
|
+
def active?(profile)
|
74
|
+
cards.key?(profile.id)
|
75
|
+
end
|
76
|
+
|
77
|
+
def mask(card)
|
78
|
+
MaskedCardDetails.new(
|
79
|
+
last_four: last_four_from(card))
|
80
|
+
end
|
81
|
+
|
82
|
+
def last_four_from(card)
|
83
|
+
card.number[12..-1]
|
84
|
+
end
|
85
|
+
|
86
|
+
def cards
|
87
|
+
@cards ||= {}
|
88
|
+
end
|
89
|
+
|
90
|
+
def plans
|
91
|
+
@plans ||= {}
|
92
|
+
end
|
93
|
+
|
94
|
+
class NullCard
|
95
|
+
def number
|
96
|
+
'?' * 16
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,340 @@
|
|
1
|
+
require 'recurly'
|
2
|
+
require_relative 'entities'
|
3
|
+
|
4
|
+
module Relish
|
5
|
+
module Billing
|
6
|
+
class RecurlyApiDriver
|
7
|
+
|
8
|
+
def self.configure
|
9
|
+
yield config
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.config
|
13
|
+
@config ||= Config.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def clear_test_data
|
17
|
+
self.class.config.environment.clear_test_data
|
18
|
+
end
|
19
|
+
|
20
|
+
def fixtures
|
21
|
+
card = lambda do |number|
|
22
|
+
CardDetails.new(first_name: 'Test first name',
|
23
|
+
last_name: 'Test last name',
|
24
|
+
number: number,
|
25
|
+
cvv: '123',
|
26
|
+
expiry_month: 11,
|
27
|
+
expiry_year: 2015)
|
28
|
+
end
|
29
|
+
|
30
|
+
{
|
31
|
+
valid_credit_card: card['4111111111111111'],
|
32
|
+
invalid_credit_card: card['4000000000000002'],
|
33
|
+
other_valid_credit_card: card['378282246310005'],
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def set_plan(profile)
|
38
|
+
ensure_account(profile) do |account|
|
39
|
+
ensure_subscription(profile, account) do |subscription|
|
40
|
+
begin
|
41
|
+
subscription.update_attributes! plan_code: profile.plan
|
42
|
+
rescue Recurly::Resource::Invalid
|
43
|
+
raise InvalidPlanError
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def save_card(profile, card)
|
51
|
+
ensure_account(profile) do |account|
|
52
|
+
account.billing_info = build.billing_info(card)
|
53
|
+
account.billing_info.save!
|
54
|
+
end
|
55
|
+
set_plan(profile)
|
56
|
+
rescue Recurly::Transaction::DeclinedError,
|
57
|
+
Recurly::Resource::Invalid => exception
|
58
|
+
raise CardDeclinedError, exception.message
|
59
|
+
rescue ArgumentError,
|
60
|
+
NoMethodError
|
61
|
+
raise CardDeclinedError
|
62
|
+
ensure
|
63
|
+
self
|
64
|
+
end
|
65
|
+
|
66
|
+
def cancel_subscription(profile)
|
67
|
+
ensure_account(profile) do |account|
|
68
|
+
ensure_subscription(profile, account) do |subscription|
|
69
|
+
subscription.cancel
|
70
|
+
end
|
71
|
+
end
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
def account_status(profile)
|
76
|
+
ensure_account(profile) do |account|
|
77
|
+
ensure_subscription(profile, account) do |subscription|
|
78
|
+
return build.account_status(subscription, card_details(account))
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def card_details(account)
|
86
|
+
return CardDetails.new unless account.billing_info
|
87
|
+
build.masked_card(account.billing_info)
|
88
|
+
end
|
89
|
+
|
90
|
+
def ensure_account(profile)
|
91
|
+
yield Recurly::Account.find(profile.id)
|
92
|
+
rescue Recurly::Resource::NotFound
|
93
|
+
yield Recurly::Account.create! build.account(profile)
|
94
|
+
ensure
|
95
|
+
self
|
96
|
+
end
|
97
|
+
|
98
|
+
def ensure_subscription(profile, account)
|
99
|
+
unless account.billing_info
|
100
|
+
yield NoSubscription.new(profile) if block_given?
|
101
|
+
return self
|
102
|
+
end
|
103
|
+
|
104
|
+
unless account.subscriptions.any?
|
105
|
+
Recurly::Subscription.create! build.subscription(profile, account)
|
106
|
+
account.reload
|
107
|
+
end
|
108
|
+
|
109
|
+
yield account.subscriptions.first if block_given?
|
110
|
+
self
|
111
|
+
end
|
112
|
+
|
113
|
+
def build
|
114
|
+
Module.new do
|
115
|
+
extend self
|
116
|
+
|
117
|
+
def account(profile)
|
118
|
+
{
|
119
|
+
account_code: profile.id,
|
120
|
+
email: profile.email
|
121
|
+
}
|
122
|
+
end
|
123
|
+
|
124
|
+
def subscription(profile, account)
|
125
|
+
{
|
126
|
+
plan_code: profile.plan,
|
127
|
+
currency: 'USD',
|
128
|
+
account: account,
|
129
|
+
trial_ends_at: profile.trial_end_date
|
130
|
+
}
|
131
|
+
end
|
132
|
+
|
133
|
+
def billing_info(card)
|
134
|
+
{
|
135
|
+
first_name: card.first_name,
|
136
|
+
last_name: card.last_name,
|
137
|
+
number: card.number,
|
138
|
+
month: card.expiry_month,
|
139
|
+
year: card.expiry_year,
|
140
|
+
verification_value: card.cvv,
|
141
|
+
address1: card.address1,
|
142
|
+
address2: card.address2,
|
143
|
+
city: card.city,
|
144
|
+
state: card.state,
|
145
|
+
zip: card.zip,
|
146
|
+
country: card.country
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
def masked_card(billing_info)
|
151
|
+
MaskedCardDetails.new(
|
152
|
+
last_four: billing_info.last_four
|
153
|
+
)
|
154
|
+
end
|
155
|
+
|
156
|
+
def account_status(subscription, card_details)
|
157
|
+
AccountStatus.new(
|
158
|
+
plan_id: subscription.plan.plan_code,
|
159
|
+
card_details: card_details,
|
160
|
+
next_billing_date: subscription.current_period_ends_at.to_date,
|
161
|
+
non_renewing: subscription.state == 'canceled',
|
162
|
+
active: subscription.state == 'active')
|
163
|
+
end
|
164
|
+
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
class NoSubscription
|
169
|
+
def initialize(profile)
|
170
|
+
@profile = profile
|
171
|
+
end
|
172
|
+
|
173
|
+
def plan
|
174
|
+
NoPlan.new
|
175
|
+
end
|
176
|
+
|
177
|
+
def update_attributes!(*)
|
178
|
+
# noop
|
179
|
+
end
|
180
|
+
|
181
|
+
def cancel
|
182
|
+
# noop
|
183
|
+
end
|
184
|
+
|
185
|
+
def current_period_ends_at
|
186
|
+
@profile.trial_end_date
|
187
|
+
end
|
188
|
+
|
189
|
+
def state
|
190
|
+
'non-existent'
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
class NoPlan
|
195
|
+
def plan_code
|
196
|
+
'????'
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def upgrade_plan_to(new_plan_code, account_code)
|
201
|
+
new_plan = {
|
202
|
+
plan_code: new_plan_code,
|
203
|
+
timeframe: 'now'
|
204
|
+
}
|
205
|
+
subscription_for_account(account_code).update_attributes! new_plan
|
206
|
+
self
|
207
|
+
rescue Recurly::Resource::Invalid
|
208
|
+
raise InvalidPlanError
|
209
|
+
end
|
210
|
+
|
211
|
+
def subscription_for_account(account_code)
|
212
|
+
account = retrieve_account(account_code)
|
213
|
+
account.subscriptions.first
|
214
|
+
end
|
215
|
+
|
216
|
+
def retrieve_account(account_code)
|
217
|
+
Recurly::Account.find(account_code.to_s)
|
218
|
+
rescue Recurly::Resource::NotFound
|
219
|
+
raise AccountNotFoundError.new(account_code)
|
220
|
+
end
|
221
|
+
|
222
|
+
class Config
|
223
|
+
attr_reader :environment
|
224
|
+
|
225
|
+
def environment=(name)
|
226
|
+
@environment = env(name).configure
|
227
|
+
self
|
228
|
+
end
|
229
|
+
|
230
|
+
def enable_logging
|
231
|
+
require 'logger'
|
232
|
+
Recurly.logger = Logger.new STDOUT
|
233
|
+
end
|
234
|
+
|
235
|
+
private
|
236
|
+
|
237
|
+
def env(name)
|
238
|
+
envs = {
|
239
|
+
test: Test,
|
240
|
+
production: Production
|
241
|
+
}
|
242
|
+
envs.fetch(name).new
|
243
|
+
end
|
244
|
+
|
245
|
+
class Environment
|
246
|
+
def configure
|
247
|
+
Recurly.api_key = api_key
|
248
|
+
Recurly.js.private_key = js_private_key
|
249
|
+
self
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Production
|
254
|
+
# matt@mattwynne.net
|
255
|
+
# Recurly.api_key = '843cf121e28149309292c9b500e34e1b'
|
256
|
+
# Recurly.js.private_key = 'a716ce2fcc904310b2ace41809ed9eb9'
|
257
|
+
class Production < Environment
|
258
|
+
VariableNotSet = Class.new(StandardError) do
|
259
|
+
def message
|
260
|
+
"Environment variable '#{super}' not set. Other values are: #{ENV.inspect}"
|
261
|
+
end
|
262
|
+
end
|
263
|
+
AttemptToClearProductionData = Class.new(StandardError)
|
264
|
+
|
265
|
+
def clear_test_data
|
266
|
+
raise AttemptToClearProductionData
|
267
|
+
end
|
268
|
+
|
269
|
+
private
|
270
|
+
|
271
|
+
def api_key
|
272
|
+
get_env_var('RECURLY_API_KEY')
|
273
|
+
end
|
274
|
+
|
275
|
+
def js_private_key
|
276
|
+
get_env_var('RECURLY_JS_PRIVATE_KEY')
|
277
|
+
end
|
278
|
+
|
279
|
+
def get_env_var(name)
|
280
|
+
ENV[name] || raise(VariableNotSet.new(name))
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
# Test
|
285
|
+
# support@relishapp.com / relishapp
|
286
|
+
# Recurly.api_key = '300041d517734cddaa84c705522d7354'
|
287
|
+
# Recurly.js.private_key = '9e052cad35774060ab4003b0c71315d9'
|
288
|
+
class Test < Environment
|
289
|
+
|
290
|
+
def clear_test_data
|
291
|
+
recurly_website.clear_test_data
|
292
|
+
end
|
293
|
+
|
294
|
+
private
|
295
|
+
|
296
|
+
def api_key
|
297
|
+
'300041d517734cddaa84c705522d7354'
|
298
|
+
end
|
299
|
+
|
300
|
+
def js_private_key
|
301
|
+
'9e052cad35774060ab4003b0c71315d9'
|
302
|
+
end
|
303
|
+
|
304
|
+
private
|
305
|
+
|
306
|
+
def recurly_website
|
307
|
+
@recurly_website ||= RecurlyWebsite.new
|
308
|
+
end
|
309
|
+
|
310
|
+
class RecurlyWebsite
|
311
|
+
def clear_test_data
|
312
|
+
require 'capybara/webkit'
|
313
|
+
Capybara.default_wait_time = (ENV['WAIT_FOR_RECURLY'] || 60).to_i
|
314
|
+
puts "Calling Recurly to clear test data..."
|
315
|
+
session.visit('https://relish-test.recurly.com/configuration/test_data')
|
316
|
+
session.execute_script 'window.confirm = function () { return true }'
|
317
|
+
session.click_on 'Delete All Test Data'
|
318
|
+
session.visit 'https://relish-test.recurly.com/accounts'
|
319
|
+
session.assert_no_selector('.account')
|
320
|
+
end
|
321
|
+
|
322
|
+
private
|
323
|
+
|
324
|
+
def session
|
325
|
+
@session ||= begin
|
326
|
+
session = Capybara::Session.new(:webkit)
|
327
|
+
session.visit('https://relish-test.recurly.com/configuration/test_data')
|
328
|
+
session.fill_in 'Email', with: 'support@relishapp.com'
|
329
|
+
session.fill_in 'Password', with: 'relishapp'
|
330
|
+
session.click_button 'Log In'
|
331
|
+
session
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|