relish-billing 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/.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
|