reji 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|