reji 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +14 -0
  3. data/.gitattributes +4 -0
  4. data/.gitignore +15 -0
  5. data/.travis.yml +28 -0
  6. data/Appraisals +17 -0
  7. data/Gemfile +8 -0
  8. data/Gemfile.lock +133 -0
  9. data/LICENSE +20 -0
  10. data/README.md +1285 -0
  11. data/Rakefile +21 -0
  12. data/app/controllers/reji/payment_controller.rb +31 -0
  13. data/app/controllers/reji/webhook_controller.rb +170 -0
  14. data/app/views/payment.html.erb +228 -0
  15. data/app/views/receipt.html.erb +250 -0
  16. data/bin/setup +12 -0
  17. data/config/routes.rb +6 -0
  18. data/gemfiles/rails_5.0.gemfile +13 -0
  19. data/gemfiles/rails_5.1.gemfile +13 -0
  20. data/gemfiles/rails_5.2.gemfile +13 -0
  21. data/gemfiles/rails_6.0.gemfile +13 -0
  22. data/lib/generators/reji/install/install_generator.rb +69 -0
  23. data/lib/generators/reji/install/templates/db/migrate/add_reji_to_users.rb.erb +16 -0
  24. data/lib/generators/reji/install/templates/db/migrate/create_subscription_items.rb.erb +19 -0
  25. data/lib/generators/reji/install/templates/db/migrate/create_subscriptions.rb.erb +22 -0
  26. data/lib/generators/reji/install/templates/reji.rb +36 -0
  27. data/lib/reji.rb +75 -0
  28. data/lib/reji/billable.rb +13 -0
  29. data/lib/reji/concerns/interacts_with_payment_behavior.rb +33 -0
  30. data/lib/reji/concerns/manages_customer.rb +113 -0
  31. data/lib/reji/concerns/manages_invoices.rb +136 -0
  32. data/lib/reji/concerns/manages_payment_methods.rb +202 -0
  33. data/lib/reji/concerns/manages_subscriptions.rb +91 -0
  34. data/lib/reji/concerns/performs_charges.rb +36 -0
  35. data/lib/reji/concerns/prorates.rb +38 -0
  36. data/lib/reji/configuration.rb +59 -0
  37. data/lib/reji/engine.rb +4 -0
  38. data/lib/reji/errors.rb +66 -0
  39. data/lib/reji/invoice.rb +243 -0
  40. data/lib/reji/invoice_line_item.rb +98 -0
  41. data/lib/reji/payment.rb +61 -0
  42. data/lib/reji/payment_method.rb +32 -0
  43. data/lib/reji/subscription.rb +567 -0
  44. data/lib/reji/subscription_builder.rb +206 -0
  45. data/lib/reji/subscription_item.rb +97 -0
  46. data/lib/reji/tax.rb +48 -0
  47. data/lib/reji/version.rb +5 -0
  48. data/reji.gemspec +32 -0
  49. data/spec/dummy/app/models/user.rb +21 -0
  50. data/spec/dummy/application.rb +53 -0
  51. data/spec/dummy/config/database.yml +11 -0
  52. data/spec/dummy/db/schema.rb +40 -0
  53. data/spec/feature/charges_spec.rb +67 -0
  54. data/spec/feature/customer_spec.rb +23 -0
  55. data/spec/feature/invoices_spec.rb +73 -0
  56. data/spec/feature/multiplan_subscriptions_spec.rb +319 -0
  57. data/spec/feature/payment_methods_spec.rb +149 -0
  58. data/spec/feature/pending_updates_spec.rb +77 -0
  59. data/spec/feature/subscriptions_spec.rb +650 -0
  60. data/spec/feature/webhooks_spec.rb +162 -0
  61. data/spec/spec_helper.rb +27 -0
  62. data/spec/support/feature_helpers.rb +39 -0
  63. data/spec/unit/customer_spec.rb +54 -0
  64. data/spec/unit/invoice_line_item_spec.rb +72 -0
  65. data/spec/unit/invoice_spec.rb +192 -0
  66. data/spec/unit/payment_spec.rb +33 -0
  67. data/spec/unit/subscription_spec.rb +103 -0
  68. metadata +237 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class InvoiceLineItem
5
+ def initialize(invoice, item)
6
+ @invoice = invoice
7
+ @item = item
8
+ end
9
+
10
+ # Get the total for the invoice line item.
11
+ def total
12
+ self.format_amount(@item.amount)
13
+ end
14
+
15
+ # Determine if the line item has both inclusive and exclusive tax.
16
+ def has_both_inclusive_and_exclusive_tax
17
+ self.inclusive_tax_percentage > 0 && self.exclusive_tax_percentage > 0
18
+ end
19
+
20
+ # Get the total percentage of the default inclusive tax for the invoice line item.
21
+ def inclusive_tax_percentage
22
+ @invoice.is_not_tax_exempt ?
23
+ self.calculate_tax_percentage_by_tax_amount(true) :
24
+ self.calculate_tax_percentage_by_tax_rate(true)
25
+ end
26
+
27
+ # Get the total percentage of the default exclusive tax for the invoice line item.
28
+ def exclusive_tax_percentage
29
+ @invoice.is_not_tax_exempt ?
30
+ self.calculate_tax_percentage_by_tax_amount(false) :
31
+ self.calculate_tax_percentage_by_tax_rate(false)
32
+ end
33
+
34
+ # Determine if the invoice line item has tax rates.
35
+ def has_tax_rates
36
+ @invoice.is_not_tax_exempt ?
37
+ ! @item.tax_amounts.empty? :
38
+ ! @item.tax_rates.empty?
39
+ end
40
+
41
+ # Get a human readable date for the start date.
42
+ def start_date
43
+ self.is_subscription ? Time.at(@item.period.start).strftime('%b %d, %Y') : nil
44
+ end
45
+
46
+ # Get a human readable date for the end date.
47
+ def end_date
48
+ self.is_subscription ? Time.at(@item.period.end).strftime('%b %d, %Y') : nil
49
+ end
50
+
51
+ # Determine if the invoice line item is for a subscription.
52
+ def is_subscription
53
+ @item.type == 'subscription'
54
+ end
55
+
56
+ # Get the Stripe model instance.
57
+ def invoice
58
+ @invoice
59
+ end
60
+
61
+ # Get the underlying Stripe invoice line item.
62
+ def as_stripe_invoice_line_item
63
+ @item
64
+ end
65
+
66
+ # Dynamically access the Stripe invoice line item instance.
67
+ def method_missing(key)
68
+ @item[key]
69
+ end
70
+
71
+ protected
72
+
73
+ # Calculate the total tax percentage for either the inclusive or exclusive tax by tax rate.
74
+ def calculate_tax_percentage_by_tax_rate(inclusive)
75
+ return 0 if @item[:tax_rates].empty?
76
+
77
+ @item.tax_rates
78
+ .select { |tax_rate| tax_rate[:inclusive] == inclusive }
79
+ .inject(0) { |sum, tax_rate| sum + tax_rate[:percentage] }
80
+ .to_i
81
+ end
82
+
83
+ # Calculate the total tax percentage for either the inclusive or exclusive tax by tax amount.
84
+ def calculate_tax_percentage_by_tax_amount(inclusive)
85
+ return 0 if @item[:tax_amounts].blank?
86
+
87
+ @item.tax_amounts
88
+ .select { |tax_amount| tax_amount.inclusive == inclusive }
89
+ .inject(0) { |sum, tax_amount| sum + tax_amount[:tax_rate][:percentage] }
90
+ .to_i
91
+ end
92
+
93
+ # Format the given amount into a displayable currency.
94
+ def format_amount(amount)
95
+ Reji.format_amount(amount, @item.currency)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class Payment
5
+ def initialize(payment_intent)
6
+ @payment_intent = payment_intent
7
+ end
8
+
9
+ # Get the total amount that will be paid.
10
+ def amount
11
+ Reji.format_amount(self.raw_amount, @payment_intent.currency)
12
+ end
13
+
14
+ # Get the raw total amount that will be paid.
15
+ def raw_amount
16
+ @payment_intent.amount
17
+ end
18
+
19
+ # The Stripe PaymentIntent client secret.
20
+ def client_secret
21
+ @payment_intent.client_secret
22
+ end
23
+
24
+ # Determine if the payment needs a valid payment method.
25
+ def requires_payment_method
26
+ @payment_intent.status == 'requires_payment_method'
27
+ end
28
+
29
+ # Determine if the payment needs an extra action like 3D Secure.
30
+ def requires_action
31
+ @payment_intent.status == 'requires_action'
32
+ end
33
+
34
+ # Determine if the payment was cancelled.
35
+ def is_cancelled
36
+ @payment_intent.status == 'canceled'
37
+ end
38
+
39
+ # Determine if the payment was successful.
40
+ def is_succeeded
41
+ @payment_intent.status == 'succeeded'
42
+ end
43
+
44
+ # Validate if the payment intent was successful and throw an exception if not.
45
+ def validate
46
+ raise Reji::PaymentFailureError::invalid_payment_method(self) if self.requires_payment_method
47
+
48
+ raise Reji::PaymentActionRequiredError::incomplete(self) if self.requires_action
49
+ end
50
+
51
+ # The Stripe PaymentIntent instance.
52
+ def as_stripe_payment_intent
53
+ @payment_intent
54
+ end
55
+
56
+ # Dynamically get values from the Stripe PaymentIntent.
57
+ def method_missing(key)
58
+ @payment_intent[key]
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class PaymentMethod
5
+ def initialize(owner, payment_method)
6
+ raise Reji::InvalidPaymentMethodError.invalid_owner(payment_method, owner) if owner.stripe_id != payment_method.customer
7
+
8
+ @owner = owner
9
+ @payment_method = payment_method
10
+ end
11
+
12
+ # Delete the payment method.
13
+ def delete
14
+ @owner.remove_payment_method(@payment_method)
15
+ end
16
+
17
+ # Get the Stripe model instance.
18
+ def owner
19
+ @owner
20
+ end
21
+
22
+ # Get the Stripe PaymentMethod instance.
23
+ def as_stripe_payment_method
24
+ @payment_method
25
+ end
26
+
27
+ # Dynamically get values from the Stripe PaymentMethod.
28
+ def method_missing(key)
29
+ @payment_method[key]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,567 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reji
4
+ class Subscription < ActiveRecord::Base
5
+ include Reji::InteractsWithPaymentBehavior
6
+ include Reji::Prorates
7
+
8
+ has_many :items, class_name: 'SubscriptionItem'
9
+ belongs_to :owner, class_name: Reji.configuration.model, foreign_key: Reji.configuration.model_id
10
+
11
+ scope :incomplete, -> { where(stripe_status: 'incomplete') }
12
+ scope :past_due, -> { where(stripe_status: 'past_due') }
13
+ scope :active, -> {
14
+ query = (where(ends_at: nil).or(on_grace_period))
15
+ .where('stripe_status != ?', 'incomplete')
16
+ .where('stripe_status != ?', 'incomplete_expired')
17
+ .where('stripe_status != ?', 'unpaid')
18
+
19
+ query.where('stripe_status != ?', 'past_due') if Reji.deactivate_past_due
20
+
21
+ query
22
+ }
23
+ scope :recurring, -> { not_on_trial.not_cancelled }
24
+ scope :cancelled, -> { where.not(ends_at: nil) }
25
+ scope :not_cancelled, -> { where(ends_at: nil) }
26
+ scope :ended, -> { cancelled.not_on_grace_period }
27
+ scope :on_trial, -> { where.not(trial_ends_at: nil).where('trial_ends_at > ?', Time.now) }
28
+ scope :not_on_trial, -> { where(trial_ends_at: nil).or(where('trial_ends_at <= ?', Time.now)) }
29
+ scope :on_grace_period, -> { where.not(ends_at: nil).where('ends_at > ?', Time.now) }
30
+ scope :not_on_grace_period, -> { where(ends_at: nil).or(where('ends_at <= ?', Time.now)) }
31
+
32
+ # The date on which the billing cycle should be anchored.
33
+ @billing_cycle_anchor = nil
34
+
35
+ # Get the user that owns the subscription.
36
+ def user
37
+ self.owner
38
+ end
39
+
40
+ # Determine if the subscription has multiple plans.
41
+ def has_multiple_plans
42
+ self.stripe_plan.nil?
43
+ end
44
+
45
+ # Determine if the subscription has a single plan.
46
+ def has_single_plan
47
+ ! self.has_multiple_plans
48
+ end
49
+
50
+ # Determine if the subscription has a specific plan.
51
+ def has_plan(plan)
52
+ return self.items.any? { |item| item.stripe_plan == plan } if self.has_multiple_plans
53
+
54
+ self.stripe_plan == plan
55
+ end
56
+
57
+ # Get the subscription item for the given plan.
58
+ def find_item_or_fail(plan)
59
+ self.items.where(stripe_plan: plan).first
60
+ end
61
+
62
+ # Determine if the subscription is active, on trial, or within its grace period.
63
+ def valid
64
+ self.active || self.on_trial || self.on_grace_period
65
+ end
66
+
67
+ # Determine if the subscription is incomplete.
68
+ def incomplete
69
+ self.stripe_status == 'incomplete'
70
+ end
71
+
72
+ # Determine if the subscription is past due.
73
+ def past_due
74
+ self.stripe_status == 'past_due'
75
+ end
76
+
77
+ # Determine if the subscription is active.
78
+ def active
79
+ (self.ends_at.nil? || self.on_grace_period) &&
80
+ self.stripe_status != 'incomplete' &&
81
+ self.stripe_status != 'incomplete_expired' &&
82
+ self.stripe_status != 'unpaid' &&
83
+ (! Reji.deactivate_past_due || self.stripe_status != 'past_due')
84
+ end
85
+
86
+ # Sync the Stripe status of the subscription.
87
+ def sync_stripe_status
88
+ subscription = self.as_stripe_subscription
89
+
90
+ self.update({stripe_status: subscription.status})
91
+ end
92
+
93
+ # Determine if the subscription is recurring and not on trial.
94
+ def recurring
95
+ ! self.on_trial && ! self.cancelled
96
+ end
97
+
98
+ # Determine if the subscription is no longer active.
99
+ def cancelled
100
+ ! self.ends_at.nil?
101
+ end
102
+
103
+ # Determine if the subscription has ended and the grace period has expired.
104
+ def ended
105
+ !! (self.cancelled && ! self.on_grace_period)
106
+ end
107
+
108
+ # Determine if the subscription is within its trial period.
109
+ def on_trial
110
+ !! (self.trial_ends_at && self.trial_ends_at.future?)
111
+ end
112
+
113
+ # Determine if the subscription is within its grace period after cancellation.
114
+ def on_grace_period
115
+ !! (self.ends_at && self.ends_at.future?)
116
+ end
117
+
118
+ # Increment the quantity of the subscription.
119
+ def increment_quantity(count = 1, plan = nil)
120
+ self.guard_against_incomplete
121
+
122
+ if plan
123
+ self.find_item_or_fail(plan)
124
+ .set_proration_behavior(self.prorate_behavior)
125
+ .increment_quantity(count)
126
+
127
+ return self
128
+ end
129
+
130
+ self.guard_against_multiple_plans
131
+
132
+ self.update_quantity(self.quantity + count, plan)
133
+ end
134
+
135
+ # Increment the quantity of the subscription, and invoice immediately.
136
+ def increment_and_invoice(count = 1, plan = nil)
137
+ self.guard_against_incomplete
138
+
139
+ self.always_invoice
140
+
141
+ if plan
142
+ self.find_item_or_fail(plan)
143
+ .set_proration_behavior(self.prorate_behavior)
144
+ .increment_quantity(count)
145
+
146
+ return self
147
+ end
148
+
149
+ self.guard_against_multiple_plans
150
+
151
+ self.increment_quantity(count, plan)
152
+ end
153
+
154
+ # Decrement the quantity of the subscription.
155
+ def decrement_quantity(count = 1, plan = nil)
156
+ self.guard_against_incomplete
157
+
158
+ if plan
159
+ self.find_item_or_fail(plan)
160
+ .set_proration_behavior(self.prorate_behavior)
161
+ .decrement_quantity(count)
162
+
163
+ return self
164
+ end
165
+
166
+ self.guard_against_multiple_plans
167
+
168
+ self.update_quantity([1, self.quantity - count].max, plan)
169
+ end
170
+
171
+ # Update the quantity of the subscription.
172
+ def update_quantity(quantity, plan = nil)
173
+ self.guard_against_incomplete
174
+
175
+ if plan
176
+ self.find_item_or_fail(plan)
177
+ .set_proration_behavior(self.prorate_behavior)
178
+ .update_quantity(quantity)
179
+
180
+ return self
181
+ end
182
+
183
+ self.guard_against_multiple_plans
184
+
185
+ stripe_subscription = self.as_stripe_subscription
186
+ stripe_subscription.quantity = quantity
187
+ stripe_subscription.payment_behavior = self.payment_behavior
188
+ stripe_subscription.proration_behavior = self.prorate_behavior
189
+ stripe_subscription.save
190
+
191
+ self.update(quantity: quantity)
192
+
193
+ self
194
+ end
195
+
196
+ # Change the billing cycle anchor on a plan change.
197
+ def anchor_billing_cycle_on(date = 'now')
198
+ @billing_cycle_anchor = date
199
+
200
+ self
201
+ end
202
+
203
+ # Force the trial to end immediately.
204
+ def skip_trial
205
+ self.trial_ends_at = nil
206
+
207
+ self
208
+ end
209
+
210
+ # Extend an existing subscription's trial period.
211
+ def extend_trial(date)
212
+ raise ArgumentError.new("Extending a subscription's trial requires a date in the future.") unless date.future?
213
+
214
+ subscription = self.as_stripe_subscription
215
+ subscription.trial_end = date.to_i
216
+ subscription.save
217
+
218
+ self.update(trial_ends_at: date)
219
+
220
+ self
221
+ end
222
+
223
+ # Swap the subscription to new Stripe plans.
224
+ def swap(plans, options = {})
225
+ plans = [plans] unless plans.instance_of? Array
226
+
227
+ raise ArgumentError.new('Please provide at least one plan when swapping.') if plans.empty?
228
+
229
+ self.guard_against_incomplete
230
+
231
+ items = self.merge_items_that_should_be_deleted_during_swap(
232
+ self.parse_swap_plans(plans)
233
+ )
234
+
235
+ stripe_subscription = Stripe::Subscription::update(
236
+ self.stripe_id,
237
+ self.get_swap_options(items, options),
238
+ self.owner.stripe_options
239
+ )
240
+
241
+ self.update({
242
+ :stripe_status => stripe_subscription.status,
243
+ :stripe_plan => stripe_subscription.plan ? stripe_subscription.plan.id : nil,
244
+ :quantity => stripe_subscription.quantity,
245
+ :ends_at => nil,
246
+ })
247
+
248
+ stripe_subscription.items.each do |item|
249
+ self.items.find_or_create_by(stripe_id: item.id) do |subscription_item|
250
+ subscription_item.stripe_plan = item.plan.id
251
+ subscription_item.quantity = item.quantity
252
+ end
253
+ end
254
+
255
+ # Delete items that aren't attached to the subscription anymore...
256
+ self.items.where('stripe_plan NOT IN (?)', items.values.pluck(:plan).compact).destroy_all
257
+
258
+ if self.has_incomplete_payment
259
+ Payment.new(stripe_subscription.latest_invoice.payment_intent).validate
260
+ end
261
+
262
+ self
263
+ end
264
+
265
+ # Swap the subscription to new Stripe plans, and invoice immediately.
266
+ def swap_and_invoice(plans, options = {})
267
+ self.always_invoice
268
+
269
+ self.swap(plans, options)
270
+ end
271
+
272
+ # Add a new Stripe plan to the subscription.
273
+ def add_plan(plan, quantity = 1, options = {})
274
+ self.guard_against_incomplete
275
+
276
+ if self.items.any? { |item| item.stripe_plan == plan }
277
+ raise Reji::SubscriptionUpdateFailureError::duplicate_plan(self, plan)
278
+ end
279
+
280
+ subscription = self.as_stripe_subscription
281
+
282
+ item = subscription.items.create({
283
+ :plan => plan,
284
+ :quantity => quantity,
285
+ :tax_rates => self.get_plan_tax_rates_for_payload(plan),
286
+ :payment_behavior => self.payment_behavior,
287
+ :proration_behavior => self.prorate_behavior,
288
+ }.merge(options))
289
+
290
+ self.items.create({
291
+ :stripe_id => item.id,
292
+ :stripe_plan => plan,
293
+ :quantity => quantity
294
+ })
295
+
296
+ if self.has_single_plan
297
+ self.update({
298
+ :stripe_plan => nil,
299
+ :quantity => nil,
300
+ })
301
+ end
302
+
303
+ self
304
+ end
305
+
306
+ # Add a new Stripe plan to the subscription, and invoice immediately.
307
+ def add_plan_and_invoice(plan, quantity = 1, options = {})
308
+ self.always_invoice
309
+
310
+ self.add_plan(plan, quantity, options)
311
+ end
312
+
313
+ # Remove a Stripe plan from the subscription.
314
+ def remove_plan(plan)
315
+ raise Reji::SubscriptionUpdateFailureError::cannot_delete_last_plan(self) if self.has_single_plan
316
+
317
+ item = self.find_item_or_fail(plan)
318
+
319
+ item.as_stripe_subscription_item.delete({
320
+ :proration_behavior => self.prorate_behavior
321
+ })
322
+
323
+ self.items.where(stripe_plan: plan).destroy_all
324
+
325
+ if self.items.count < 2
326
+ item = self.items.first
327
+
328
+ self.update({
329
+ :stripe_plan => item.stripe_plan,
330
+ :quantity => quantity,
331
+ })
332
+ end
333
+
334
+ self
335
+ end
336
+
337
+ # Cancel the subscription at the end of the billing period.
338
+ def cancel
339
+ subscription = self.as_stripe_subscription
340
+
341
+ subscription.cancel_at_period_end = true
342
+
343
+ subscription = subscription.save
344
+
345
+ self.stripe_status = subscription.status
346
+
347
+ # If the user was on trial, we will set the grace period to end when the trial
348
+ # would have ended. Otherwise, we'll retrieve the end of the billing period
349
+ # period and make that the end of the grace period for this current user.
350
+ if self.on_trial
351
+ self.ends_at = self.trial_ends_at
352
+ else
353
+ self.ends_at = Time.at(subscription.current_period_end)
354
+ end
355
+
356
+ self.save
357
+
358
+ self
359
+ end
360
+
361
+ # Cancel the subscription immediately.
362
+ def cancel_now
363
+ self.as_stripe_subscription.cancel({
364
+ :prorate => self.prorate_behavior == 'create_prorations',
365
+ })
366
+
367
+ self.mark_as_cancelled
368
+
369
+ self
370
+ end
371
+
372
+ # Cancel the subscription and invoice immediately.
373
+ def cancel_now_and_invoice
374
+ self.as_stripe_subscription.cancel({
375
+ :invoice_now => true,
376
+ :prorate => self.prorate_behavior == 'create_prorations',
377
+ })
378
+
379
+ self.mark_as_cancelled
380
+
381
+ self
382
+ end
383
+
384
+ # Mark the subscription as cancelled.
385
+ def mark_as_cancelled
386
+ self.update({
387
+ :stripe_status => 'canceled',
388
+ :ends_at => Time.now,
389
+ })
390
+ end
391
+
392
+ # Resume the cancelled subscription.
393
+ def resume
394
+ raise ArgumentError.new('Unable to resume subscription that is not within grace period.') unless self.on_grace_period
395
+
396
+ subscription = self.as_stripe_subscription
397
+
398
+ subscription.cancel_at_period_end = false
399
+
400
+ if self.on_trial
401
+ subscription.trial_end = Time.at(self.trial_ends_at).to_i
402
+ else
403
+ subscription.trial_end = 'now'
404
+ end
405
+
406
+ subscription = subscription.save
407
+
408
+ # Finally, we will remove the ending timestamp from the user's record in the
409
+ # local database to indicate that the subscription is active again and is
410
+ # no longer "cancelled". Then we will save this record in the database.
411
+ self.update({
412
+ :stripe_status => subscription.status,
413
+ :ends_at => nil,
414
+ })
415
+
416
+ self
417
+ end
418
+
419
+ # Determine if the subscription has pending updates.
420
+ def pending
421
+ ! self.as_stripe_subscription.pending_update.nil?
422
+ end
423
+
424
+ # Invoice the subscription outside of the regular billing cycle.
425
+ def invoice(options = {})
426
+ begin
427
+ self.user.invoice(options.merge({
428
+ :subscription => self.stripe_id
429
+ }))
430
+ rescue IncompletePaymentError => e
431
+ # Set the new Stripe subscription status immediately when payment fails...
432
+ self.update(stripe_status: e.payment.invoice.subscription.status)
433
+
434
+ raise e
435
+ end
436
+ end
437
+
438
+ # Get the latest invoice for the subscription.
439
+ def latest_invoice
440
+ stripe_subscription = self.as_stripe_subscription(['latest_invoice'])
441
+
442
+ Invoice.new(self.user, stripe_subscription.latest_invoice)
443
+ end
444
+
445
+ # Sync the tax percentage of the user to the subscription.
446
+ def sync_tax_percentage
447
+ subscription = self.as_stripe_subscription
448
+
449
+ subscription.tax_percentage = self.user.tax_percentage
450
+
451
+ subscription.save
452
+ end
453
+
454
+ # Sync the tax rates of the user to the subscription.
455
+ def sync_tax_rates
456
+ subscription = self.as_stripe_subscription
457
+
458
+ subscription.default_tax_rates = self.user.tax_rates
459
+
460
+ subscription.save
461
+
462
+ self.items.each do |item|
463
+ stripe_subscription_item = item.as_stripe_subscription_item
464
+
465
+ stripe_subscription_item.tax_rates = self.get_plan_tax_rates_for_payload(item.stripe_plan)
466
+
467
+ stripe_subscription_item.save
468
+ end
469
+ end
470
+
471
+ # Get the plan tax rates for the Stripe payload.
472
+ def get_plan_tax_rates_for_payload(plan)
473
+ tax_rates = self.user.plan_tax_rates
474
+
475
+ if tax_rates
476
+ tax_rates.key?(plan) ? tax_rates[plan] : nil
477
+ end
478
+ end
479
+
480
+ # Determine if the subscription has an incomplete payment.
481
+ def has_incomplete_payment
482
+ self.past_due || self.incomplete
483
+ end
484
+
485
+ # Get the latest payment for a Subscription.
486
+ def latest_payment
487
+ payment_intent = self.as_stripe_subscription(['latest_invoice.payment_intent'])
488
+ .latest_invoice
489
+ .payment_intent
490
+
491
+ payment_intent ? Payment.new(payment_intent) : nil
492
+ end
493
+
494
+ # Make sure a subscription is not incomplete when performing changes.
495
+ def guard_against_incomplete
496
+ raise Reji::SubscriptionUpdateFailureError.incomplete_subscription(self) if self.incomplete
497
+ end
498
+
499
+ # Make sure a plan argument is provided when the subscription is a multi plan subscription.
500
+ def guard_against_multiple_plans
501
+ raise ArgumentError.new('This method requires a plan argument since the subscription has multiple plans.') if self.has_multiple_plans
502
+ end
503
+
504
+ # Update the underlying Stripe subscription information for the model.
505
+ def update_stripe_subscription(options = {})
506
+ Stripe::Subscription.update(
507
+ self.stripe_id, options, self.owner.stripe_options
508
+ )
509
+ end
510
+
511
+ # Get the subscription as a Stripe subscription object.
512
+ def as_stripe_subscription(expand = {})
513
+ Stripe::Subscription::retrieve(
514
+ {:id => self.stripe_id, :expand => expand}, self.owner.stripe_options
515
+ )
516
+ end
517
+
518
+ protected
519
+
520
+ # Parse the given plans for a swap operation.
521
+ def parse_swap_plans(plans)
522
+ plans.map {
523
+ |plan| [plan, {
524
+ :plan => plan,
525
+ :tax_rates => self.get_plan_tax_rates_for_payload(plan)
526
+ }]
527
+ }.to_h
528
+ end
529
+
530
+ # Merge the items that should be deleted during swap into the given items collection.
531
+ def merge_items_that_should_be_deleted_during_swap(items)
532
+ self.as_stripe_subscription.items.data.each do |stripe_subscription_item|
533
+ plan = stripe_subscription_item.plan.id
534
+
535
+ item = items.key?(plan) ? items[plan] : {}
536
+
537
+ if item.empty?
538
+ item[:deleted] = true
539
+ end
540
+
541
+ items[plan] = item.merge({:id => stripe_subscription_item.id})
542
+ end
543
+
544
+ items
545
+ end
546
+
547
+ # Get the options array for a swap operation.
548
+ def get_swap_options(items, options)
549
+ payload = {
550
+ :items => items.values,
551
+ :payment_behavior => self.payment_behavior,
552
+ :proration_behavior => self.prorate_behavior,
553
+ :expand => ['latest_invoice.payment_intent'],
554
+ }
555
+
556
+ payload[:cancel_at_period_end] = false if payload[:payment_behavior] != 'pending_if_incomplete'
557
+
558
+ payload = payload.merge(options)
559
+
560
+ payload[:billing_cycle_anchor] = @billing_cycle_anchor unless @billing_cycle_anchor.nil?
561
+
562
+ payload[:trial_end] = self.on_trial ? self.trial_ends_at : 'now'
563
+
564
+ payload
565
+ end
566
+ end
567
+ end