reji 1.0.0

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