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