Class: Pay::Stripe::Subscription

Inherits:
Pay::Subscription
  • Object
show all
Extended by:
Pay::Sync
Defined in:
app/models/pay/stripe/subscription.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Pay::Sync

find_pay_customer, pay_processor, sync_with_retries

Instance Attribute Details

#api_record(**options) ⇒ Object



140
141
142
143
144
# File 'app/models/pay/stripe/subscription.rb', line 140

def api_record(**options)
  @api_record ||= ::Stripe::Subscription.retrieve(options.with_defaults(id: processor_id).merge(expand_options), {stripe_account: }.compact)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

Class Method Details

.expand_optionsObject

Common expand options for all requests that create, retrieve, or update a Stripe Subscription



117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'app/models/pay/stripe/subscription.rb', line 117

def self.expand_options
  {
    expand: [
      "default_payment_method",
      "discounts",
      "latest_invoice.confirmation_secret",
      "latest_invoice.payments",
      "latest_invoice.total_discount_amounts.discount",
      "pending_setup_intent",
      "schedule"
    ]
  }
end

.sync(subscription_id, object: nil, name: nil, stripe_account: nil, retries: 1) ⇒ Object



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'app/models/pay/stripe/subscription.rb', line 13

def self.sync(subscription_id, object: nil, name: nil, stripe_account: nil, retries: 1)
  sync_with_retries(retries: retries) do
    subscription = object || ::Stripe::Subscription.retrieve({id: subscription_id}.merge(expand_options), {stripe_account: }.compact)
    return unless (pay_customer = find_pay_customer(subscription.customer))
     ||= pay_customer.

    attributes = {
      object: subscription.to_hash,
      application_fee_percent: subscription.application_fee_percent,
      created_at: Time.at(subscription.created),
      processor_plan: subscription.items.first.price.id,
      quantity: subscription.items.first.try(:quantity) || 0,
      status: subscription.status,
      stripe_account: ,
      metadata: subscription.,
      metered: false,
      pause_behavior: subscription.pause_collection&.behavior,
      pause_resumes_at: (subscription.pause_collection&.resumes_at ? Time.at(subscription.pause_collection&.resumes_at) : nil),
      current_period_start: (subscription.items.first.current_period_start ? Time.at(subscription.items.first.current_period_start) : nil),
      current_period_end: (subscription.items.first.current_period_end ? Time.at(subscription.items.first.current_period_end) : nil)
    }

    # Subscriptions that have ended should have their trial ended at the
    # same time if they were still on trial (if you cancel a
    # subscription, your are cancelling your trial as well at the same
    # instant). This avoids canceled subscriptions responding `true`
    # to #on_trial? due to the `trial_ends_at` being left set in the
    # future.
    if subscription.trial_end
      trial_ended_at = [subscription.ended_at, subscription.trial_end].compact.min
      attributes[:trial_ends_at] = Time.at(trial_ended_at)
    else
      attributes[:trial_ends_at] = nil
    end

    subscription.items.auto_paging_each do |subscription_item|
      next if attributes[:metered]
      attributes[:metered] = true if subscription_item.price.try(:recurring).try(:usage_type) == "metered"
    end

    attributes[:ends_at] = if subscription.ended_at
      # Fully cancelled subscription
      Time.at(subscription.ended_at)
    elsif subscription.cancel_at
      # subscription cancelling in the future
      Time.at(subscription.cancel_at)
    elsif subscription.cancel_at_period_end
      # Subscriptions cancelling in the future
      Time.at(subscription.items.first.current_period_end)
    end

    # Sync payment method if directly attached to subscription
    if subscription.default_payment_method
      if subscription.default_payment_method.is_a? String
        Pay::Stripe::PaymentMethod.sync(subscription.default_payment_method, stripe_account: )
        attributes[:payment_method_id] = subscription.default_payment_method
      else
        Pay::Stripe::PaymentMethod.sync(subscription.default_payment_method.id, object: subscription.default_payment_method, stripe_account: )
        attributes[:payment_method_id] = subscription.default_payment_method.id
      end
    end

    # Update or create the subscription
    pay_subscription = find_by(customer: pay_customer, processor_id: subscription.id)
    if pay_subscription
      # If pause behavior is changing to `void`, record the pause start date
      # Any other pause status (or no pause at all) should have nil for start
      if pay_subscription.pause_behavior != attributes[:pause_behavior]
        attributes[:pause_starts_at] = if attributes[:pause_behavior] == "void"
          Time.at(subscription.items.first.current_period_end)
        end
      end

      pay_subscription.with_lock { pay_subscription.update!(attributes) }
    else
      # Allow setting the subscription name in metadata, otherwise use the default
      name ||= subscription.["pay_name"] || Pay.default_product_name
      pay_subscription = create!(attributes.merge(customer: pay_customer, name: name, processor_id: subscription.id))
    end

    # Cache the Stripe subscription on the Pay::Subscription that we return
    pay_subscription.api_record = subscription

    # Sync the latest charge if we already have it loaded (like during subscrbe), otherwise, let webhooks take care of creating it
    if (invoice = subscription.try(:latest_invoice))
      Array(invoice.try(:payments)).each do |invoice_payment|
        next unless invoice_payment.status == "paid"

        case invoice_payment.payment.type
        when "payment_intent"
          Pay::Stripe::Charge.sync_payment_intent(invoice_payment.payment.payment_intent, stripe_account: )
        when "charge"
          Pay::Stripe::Charge.sync(invoice_payment.payment.charge, stripe_account: )
        end
      end
    end

    pay_subscription
  end
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

.sync_from_checkout_session(session_id, stripe_account: nil) ⇒ Object



8
9
10
11
# File 'app/models/pay/stripe/subscription.rb', line 8

def self.sync_from_checkout_session(session_id, stripe_account: nil)
  Pay.deprecator.warn "Pay::Stripe::Subscription.sync_from_checkout_session is deprecated, use Pay::Stripe.sync_checkout_session instead"
  Pay::Stripe.sync_checkout_session(session_id, stripe_account: )
end

Instance Method Details

#cancel(**options) ⇒ Object

Marks a subscription to cancel at period end

If subscription is already past_due, the subscription will be cancelled immediately To disable this, pass past_due_cancel_now: false



164
165
166
167
168
169
170
171
172
173
174
175
# File 'app/models/pay/stripe/subscription.rb', line 164

def cancel(**options)
  return if canceled?

  if past_due? && options.fetch(:past_due_cancel_now, true)
    cancel_now!
  else
    @api_record = ::Stripe::Subscription.update(processor_id, {cancel_at_period_end: true}.merge(expand_options), stripe_options)
    update(ends_at: Time.at(@api_record.cancel_at))
  end
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#cancel_now!(**options) ⇒ Object

Cancels a subscription immediately

cancel_now!(prorate: true) cancel_now!(invoice_now: true)



181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/models/pay/stripe/subscription.rb', line 181

def cancel_now!(**options)
  return if canceled? && ends_at.past?

  @api_record = ::Stripe::Subscription.cancel(processor_id, options.merge(expand_options), stripe_options)
  update(
    trial_ends_at: (@api_record.trial_end ? Time.at(@api_record.trial_end) : nil),
    ends_at: Time.at(@api_record.ended_at),
    status: @api_record.status
  )
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#change_quantity(quantity, **options) ⇒ Object

This updates a SubscriptionItem's quantity in Stripe

Defaults to the first SubscriptionItem. Pass subscription_item_id: to update a different one (Stripe no longer accepts a top-level quantity on the subscription itself)



198
199
200
201
202
203
204
205
# File 'app/models/pay/stripe/subscription.rb', line 198

def change_quantity(quantity, **options)
  subscription_item_id = options.delete(:subscription_item_id) || subscription_items.first.id
  ::Stripe::SubscriptionItem.update(subscription_item_id, options.merge(quantity: quantity), stripe_options)
  @api_record = nil
  update(quantity: quantity)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#client_secretObject

Returns a SetupIntent or PaymentIntent client secret for the subscription



147
148
149
# File 'app/models/pay/stripe/subscription.rb', line 147

def client_secret
  api_record&.pending_setup_intent&.client_secret || api_record&.latest_invoice&.confirmation_secret&.client_secret
end

#latest_paymentObject

Returns the Stripe::PaymentIntent for the latest invoice, if there is one



363
364
365
366
367
368
# File 'app/models/pay/stripe/subscription.rb', line 363

def latest_payment
  payment_intent_id = api_record.latest_invoice&.payments&.first&.payment&.payment_intent
  ::Stripe::PaymentIntent.retrieve({id: payment_intent_id}, stripe_options) if payment_intent_id
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#metered_subscription_itemObject

Returns the first metered subscription item



320
321
322
323
324
# File 'app/models/pay/stripe/subscription.rb', line 320

def metered_subscription_item
  subscription_items.auto_paging_each do |subscription_item|
    return subscription_item if subscription_item.price.try(:recurring).try(:usage_type) == "metered"
  end
end

#on_grace_period?Boolean



207
208
209
# File 'app/models/pay/stripe/subscription.rb', line 207

def on_grace_period?
  (ends_at? && ends_at > Time.current) || (paused? && will_pause?)
end

#pause(**options) ⇒ Object

Pauses a Stripe subscription

pause(behavior: "mark_uncollectible") pause(behavior: "keep_as_draft") pause(behavior: "void") pause(behavior: "mark_uncollectible", resumes_at: 1.month.from_now)

void - If you can’t provide your services for a certain period of time, you can void invoices that are created by your subscriptions so that your customers aren’t charged. keep_as_draft - If you want to temporarily offer your services for free and collect payments later mark_uncollectible - If you want to offer your services for free

pause_behavior of void is considered active until the end of the current period and not active after that. The current_period_end is stored as pause_starts_at Other pause_behaviors do not set pause_starts_at because they are used for offering free services

https://docs.stripe.com/billing/subscriptions/pause-payment



238
239
240
241
242
243
244
245
246
247
248
249
# File 'app/models/pay/stripe/subscription.rb', line 238

def pause(**options)
  attributes = {pause_collection: options.reverse_merge(behavior: "void")}
  @api_record = ::Stripe::Subscription.update(processor_id, attributes.merge(expand_options), stripe_options)
  behavior = @api_record.pause_collection&.behavior
  update(
    pause_behavior: behavior,
    pause_resumes_at: (@api_record.pause_collection&.resumes_at ? Time.at(@api_record.pause_collection&.resumes_at) : nil),
    pause_starts_at: ((behavior == "void") ? Time.at(@api_record.items.first.current_period_end) : nil)
  )
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#pause_active?Boolean



211
212
213
# File 'app/models/pay/stripe/subscription.rb', line 211

def pause_active?
  paused? && (pause_starts_at.nil? || Time.current.after?(pause_starts_at))
end

#paused?Boolean



219
220
221
# File 'app/models/pay/stripe/subscription.rb', line 219

def paused?
  pause_behavior == "void"
end

#pay_open_invoicesObject

Looks up open invoices for a subscription and attempts to pay them

Invoices no longer expose a payment_intent directly, so the PaymentIntent is looked up through the invoice's payments



353
354
355
356
357
358
359
360
# File 'app/models/pay/stripe/subscription.rb', line 353

def pay_open_invoices
  ::Stripe::Invoice.list({subscription: processor_id, status: :open, expand: ["data.payments"]}, stripe_options).auto_paging_each do |invoice|
    payment_intent_id = invoice.payments.first&.payment&.payment_intent
    retry_failed_payment(payment_intent_id: payment_intent_id) if payment_intent_id
  end
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#preview_invoice(**options) ⇒ Object



326
327
328
329
330
# File 'app/models/pay/stripe/subscription.rb', line 326

def preview_invoice(**options)
  ::Stripe::Invoice.create_preview(options.merge(subscription: processor_id), stripe_options)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#resumable?Boolean



265
266
267
# File 'app/models/pay/stripe/subscription.rb', line 265

def resumable?
  on_grace_period? || paused?
end

#resumeObject



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'app/models/pay/stripe/subscription.rb', line 269

def resume
  unless resumable?
    raise Error, "You can only resume subscriptions within their grace period."
  end

  if paused?
    unpause
  else
    @api_record = ::Stripe::Subscription.update(processor_id, {
      trial_end: (on_trial? ? trial_ends_at.to_i : "now"),
      cancel_at_period_end: false
    }.merge(expand_options),
      stripe_options)
  end
  update(ends_at: nil, status: @api_record.status)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#retry_failed_payment(payment_intent_id: nil) ⇒ Object

Retries the latest invoice for a Past Due subscription and attempts to pay it



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/models/pay/stripe/subscription.rb', line 333

def retry_failed_payment(payment_intent_id: nil)
  payment_intent_id ||= api_record.latest_invoice.payments.first.payment.payment_intent
  payment_intent = ::Stripe::PaymentIntent.retrieve({id: payment_intent_id}, stripe_options)

  payment_intent = if payment_intent.status == "requires_payment_method"
    payment_method = customer.default_payment_method
    raise Pay::Stripe::Error, "no default payment method to retry the payment with" if payment_method.nil?

    ::Stripe::PaymentIntent.confirm(payment_intent_id, {payment_method: payment_method.processor_id}, stripe_options)
  else
    ::Stripe::PaymentIntent.confirm(payment_intent_id, {}, stripe_options)
  end
  Pay::Payment.new(payment_intent).validate
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#stripe_objectObject



131
132
133
134
# File 'app/models/pay/stripe/subscription.rb', line 131

def stripe_object
  sync! if object.nil?
  ::Stripe::Subscription.construct_from(object)
end

#subscription_itemsObject



315
316
317
# File 'app/models/pay/stripe/subscription.rb', line 315

def subscription_items
  stripe_object.items
end

#swap(plan, **options) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'app/models/pay/stripe/subscription.rb', line 288

def swap(plan, **options)
  raise ArgumentError, "plan must be a string" unless plan.is_a?(String)

  prorate = options.delete(:prorate) { true }
  proration_behavior = options.delete(:proration_behavior) || (prorate ? "always_invoice" : "none")

  @api_record = ::Stripe::Subscription.update(
    processor_id,
    {
      cancel_at_period_end: false,
      items: [{id: subscription_items.first.id, plan: plan, quantity: quantity}],
      proration_behavior: proration_behavior,
      trial_end: (on_trial? ? trial_ends_at.to_i : "now")
    }.merge(expand_options).merge(options),
    stripe_options
  )

  # Validate that swap was successful and handle SCA if needed
  if (payment_intent_id = @api_record.latest_invoice.payments.first&.payment&.payment_intent)
    Pay::Payment.from_id(payment_intent_id, stripe_account: ).validate
  end

  sync!(object: @api_record)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#sync!(**options) ⇒ Object



136
137
138
# File 'app/models/pay/stripe/subscription.rb', line 136

def sync!(**options)
  super(**options.with_defaults(stripe_account: ))
end

#unpauseObject



254
255
256
257
258
259
260
261
262
263
# File 'app/models/pay/stripe/subscription.rb', line 254

def unpause
  @api_record = ::Stripe::Subscription.update(processor_id, {pause_collection: ""}.merge(expand_options), stripe_options)
  update(
    pause_behavior: nil,
    pause_resumes_at: nil,
    pause_starts_at: nil
  )
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#update_payment_method(id) ⇒ Object

Sets the default_payment_method on a subscription Pass an empty string to unset



153
154
155
156
157
158
# File 'app/models/pay/stripe/subscription.rb', line 153

def update_payment_method(id)
  @api_record = ::Stripe::Subscription.update(processor_id, {default_payment_method: id}.merge(expand_options), stripe_options)
  update(payment_method_id: @api_record.default_payment_method&.id)
rescue ::Stripe::StripeError => e
  raise Pay::Stripe::Error, e
end

#will_pause?Boolean



215
216
217
# File 'app/models/pay/stripe/subscription.rb', line 215

def will_pause?
  pause_starts_at? && Time.current < pause_starts_at
end