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.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.gitattributes +4 -0
- data/.gitignore +15 -0
- data/.travis.yml +28 -0
- data/Appraisals +17 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +133 -0
- data/LICENSE +20 -0
- data/README.md +1285 -0
- data/Rakefile +21 -0
- data/app/controllers/reji/payment_controller.rb +31 -0
- data/app/controllers/reji/webhook_controller.rb +170 -0
- data/app/views/payment.html.erb +228 -0
- data/app/views/receipt.html.erb +250 -0
- data/bin/setup +12 -0
- data/config/routes.rb +6 -0
- data/gemfiles/rails_5.0.gemfile +13 -0
- data/gemfiles/rails_5.1.gemfile +13 -0
- data/gemfiles/rails_5.2.gemfile +13 -0
- data/gemfiles/rails_6.0.gemfile +13 -0
- data/lib/generators/reji/install/install_generator.rb +69 -0
- data/lib/generators/reji/install/templates/db/migrate/add_reji_to_users.rb.erb +16 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscription_items.rb.erb +19 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscriptions.rb.erb +22 -0
- data/lib/generators/reji/install/templates/reji.rb +36 -0
- data/lib/reji.rb +75 -0
- data/lib/reji/billable.rb +13 -0
- data/lib/reji/concerns/interacts_with_payment_behavior.rb +33 -0
- data/lib/reji/concerns/manages_customer.rb +113 -0
- data/lib/reji/concerns/manages_invoices.rb +136 -0
- data/lib/reji/concerns/manages_payment_methods.rb +202 -0
- data/lib/reji/concerns/manages_subscriptions.rb +91 -0
- data/lib/reji/concerns/performs_charges.rb +36 -0
- data/lib/reji/concerns/prorates.rb +38 -0
- data/lib/reji/configuration.rb +59 -0
- data/lib/reji/engine.rb +4 -0
- data/lib/reji/errors.rb +66 -0
- data/lib/reji/invoice.rb +243 -0
- data/lib/reji/invoice_line_item.rb +98 -0
- data/lib/reji/payment.rb +61 -0
- data/lib/reji/payment_method.rb +32 -0
- data/lib/reji/subscription.rb +567 -0
- data/lib/reji/subscription_builder.rb +206 -0
- data/lib/reji/subscription_item.rb +97 -0
- data/lib/reji/tax.rb +48 -0
- data/lib/reji/version.rb +5 -0
- data/reji.gemspec +32 -0
- data/spec/dummy/app/models/user.rb +21 -0
- data/spec/dummy/application.rb +53 -0
- data/spec/dummy/config/database.yml +11 -0
- data/spec/dummy/db/schema.rb +40 -0
- data/spec/feature/charges_spec.rb +67 -0
- data/spec/feature/customer_spec.rb +23 -0
- data/spec/feature/invoices_spec.rb +73 -0
- data/spec/feature/multiplan_subscriptions_spec.rb +319 -0
- data/spec/feature/payment_methods_spec.rb +149 -0
- data/spec/feature/pending_updates_spec.rb +77 -0
- data/spec/feature/subscriptions_spec.rb +650 -0
- data/spec/feature/webhooks_spec.rb +162 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/feature_helpers.rb +39 -0
- data/spec/unit/customer_spec.rb +54 -0
- data/spec/unit/invoice_line_item_spec.rb +72 -0
- data/spec/unit/invoice_spec.rb +192 -0
- data/spec/unit/payment_spec.rb +33 -0
- data/spec/unit/subscription_spec.rb +103 -0
- 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
|
data/lib/reji/payment.rb
ADDED
@@ -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
|