pay 3.0.24 → 4.0.2

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of pay might be problematic. Click here for more details.

Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/controllers/pay/webhooks/braintree_controller.rb +1 -1
  4. data/app/controllers/pay/webhooks/paddle_controller.rb +1 -1
  5. data/app/controllers/pay/webhooks/stripe_controller.rb +1 -1
  6. data/app/jobs/pay/customer_sync_job.rb +0 -2
  7. data/app/mailers/pay/application_mailer.rb +1 -1
  8. data/app/mailers/pay/user_mailer.rb +3 -3
  9. data/app/models/pay/charge.rb +23 -0
  10. data/app/models/pay/customer.rb +2 -6
  11. data/app/models/pay/merchant.rb +6 -0
  12. data/app/models/pay/subscription.rb +35 -8
  13. data/app/views/pay/user_mailer/receipt.html.erb +6 -6
  14. data/app/views/pay/user_mailer/refund.html.erb +6 -6
  15. data/config/locales/en.yml +31 -24
  16. data/config/routes.rb +3 -3
  17. data/lib/pay/attributes.rb +28 -2
  18. data/lib/pay/billable/sync_customer.rb +3 -3
  19. data/lib/pay/braintree/billable.rb +61 -48
  20. data/lib/pay/braintree/subscription.rb +8 -3
  21. data/lib/pay/braintree/webhooks/subscription_canceled.rb +6 -1
  22. data/lib/pay/braintree/webhooks/subscription_charged_successfully.rb +2 -2
  23. data/lib/pay/braintree/webhooks/subscription_charged_unsuccessfully.rb +1 -1
  24. data/lib/pay/braintree/webhooks/subscription_trial_ended.rb +1 -1
  25. data/lib/pay/braintree.rb +6 -2
  26. data/lib/pay/currency.rb +8 -2
  27. data/lib/pay/engine.rb +22 -4
  28. data/lib/pay/fake_processor/billable.rb +0 -4
  29. data/lib/pay/fake_processor/subscription.rb +8 -3
  30. data/lib/pay/paddle/billable.rb +0 -4
  31. data/lib/pay/paddle/subscription.rb +2 -2
  32. data/lib/pay/paddle/webhooks/signature_verifier.rb +45 -41
  33. data/lib/pay/paddle/webhooks/subscription_cancelled.rb +7 -2
  34. data/lib/pay/paddle/webhooks/subscription_payment_refunded.rb +3 -3
  35. data/lib/pay/paddle/webhooks/subscription_payment_succeeded.rb +7 -7
  36. data/lib/pay/paddle/webhooks/subscription_updated.rb +15 -15
  37. data/lib/pay/paddle.rb +14 -2
  38. data/lib/pay/receipts.rb +106 -32
  39. data/lib/pay/stripe/billable.rb +50 -18
  40. data/lib/pay/stripe/charge.rb +93 -11
  41. data/lib/pay/stripe/merchant.rb +1 -1
  42. data/lib/pay/stripe/subscription.rb +132 -30
  43. data/lib/pay/stripe/webhooks/charge_refunded.rb +2 -2
  44. data/lib/pay/stripe/webhooks/charge_succeeded.rb +2 -2
  45. data/lib/pay/stripe/webhooks/checkout_session_async_payment_succeeded.rb +1 -8
  46. data/lib/pay/stripe/webhooks/checkout_session_completed.rb +21 -2
  47. data/lib/pay/stripe/webhooks/payment_action_required.rb +6 -6
  48. data/lib/pay/stripe/webhooks/subscription_renewing.rb +6 -10
  49. data/lib/pay/stripe/webhooks/subscription_updated.rb +1 -1
  50. data/lib/pay/stripe.rb +10 -1
  51. data/lib/pay/version.rb +1 -1
  52. data/lib/pay.rb +39 -4
  53. data/lib/tasks/pay.rake +1 -1
  54. metadata +3 -4
  55. data/lib/pay/merchant.rb +0 -37
@@ -22,11 +22,36 @@ module Pay
22
22
  @pay_customer = pay_customer
23
23
  end
24
24
 
25
+ # Returns a hash of attributes for the Stripe::Customer object
26
+ def customer_attributes
27
+ owner = pay_customer.owner
28
+
29
+ attributes = case owner.class.pay_stripe_customer_attributes
30
+ when Symbol
31
+ owner.send(owner.class.pay_stripe_customer_attributes, pay_customer)
32
+ when Proc
33
+ owner.class.pay_stripe_customer_attributes.call(pay_customer)
34
+ end
35
+
36
+ # Guard against attributes being returned nil
37
+ attributes ||= {}
38
+
39
+ {email: email, name: customer_name}.merge(attributes)
40
+ end
41
+
42
+ # Retrieves a Stripe::Customer object
43
+ #
44
+ # Finds an existing Stripe::Customer if processor_id exists
45
+ # Creates a new Stripe::Customer using `customer_attributes` if empty processor_id
46
+ #
47
+ # Updates the default payment method automatically if a payment_method_token is set
48
+ #
49
+ # Returns a Stripe::Customer object
25
50
  def customer
26
51
  stripe_customer = if processor_id?
27
- ::Stripe::Customer.retrieve({id: processor_id}, stripe_options)
52
+ ::Stripe::Customer.retrieve({id: processor_id, expand: ["tax"]}, stripe_options)
28
53
  else
29
- sc = ::Stripe::Customer.create({email: email, name: customer_name}, stripe_options)
54
+ sc = ::Stripe::Customer.create(customer_attributes.merge(expand: ["tax"]), stripe_options)
30
55
  pay_customer.update!(processor_id: sc.id, stripe_account: stripe_account)
31
56
  sc
32
57
  end
@@ -45,9 +70,14 @@ module Pay
45
70
  end
46
71
 
47
72
  # Syncs name and email to Stripe::Customer
48
- def update_customer!
49
- return unless processor_id?
50
- ::Stripe::Customer.update(processor_id, {name: customer_name, email: email}, stripe_options)
73
+ # You can also pass in other attributes that will be merged into the default attributes
74
+ def update_customer!(**attributes)
75
+ customer unless processor_id?
76
+ ::Stripe::Customer.update(
77
+ processor_id,
78
+ customer_attributes.merge(attributes),
79
+ stripe_options
80
+ )
51
81
  end
52
82
 
53
83
  def charge(amount, options = {})
@@ -57,7 +87,6 @@ module Pay
57
87
  args = {
58
88
  amount: amount,
59
89
  confirm: true,
60
- confirmation_method: :automatic,
61
90
  currency: "usd",
62
91
  customer: processor_id,
63
92
  payment_method: payment_method&.processor_id
@@ -73,7 +102,7 @@ module Pay
73
102
  end
74
103
 
75
104
  def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
76
- quantity = options.delete(:quantity) || 1
105
+ quantity = options.delete(:quantity)
77
106
  opts = {
78
107
  expand: ["pending_setup_intent", "latest_invoice.payment_intent", "latest_invoice.charge.invoice"],
79
108
  items: [plan: plan, quantity: quantity],
@@ -87,13 +116,13 @@ module Pay
87
116
  opts[:customer] = customer.id
88
117
 
89
118
  # Create subscription on Stripe
90
- stripe_sub = ::Stripe::Subscription.create(opts, stripe_options)
119
+ stripe_sub = ::Stripe::Subscription.create(opts.merge(Pay::Stripe::Subscription.expand_options), stripe_options)
91
120
 
92
121
  # Save Pay::Subscription
93
122
  subscription = Pay::Stripe::Subscription.sync(stripe_sub.id, object: stripe_sub, name: name)
94
123
 
95
124
  # No trial, payment method requires SCA
96
- if subscription.incomplete?
125
+ if options[:payment_behavior].to_s != "default_incomplete" && subscription.incomplete?
97
126
  Pay::Payment.new(stripe_sub.latest_invoice.payment_intent).validate
98
127
  end
99
128
 
@@ -125,19 +154,16 @@ module Pay
125
154
 
126
155
  attributes = Pay::Stripe::PaymentMethod.extract_attributes(payment_method).merge(default: default)
127
156
 
128
- pay_customer.payment_methods.update_all(default: false) if default
157
+ # Ignore the payment method if it's already in the database
158
+ pay_customer.payment_methods.where.not(id: pay_payment_method.id).update_all(default: false) if default
129
159
  pay_payment_method.update!(attributes)
130
160
 
131
161
  # Reload the Rails association
132
- pay_customer.reload_default_payment_method if default
162
+ pay_customer.reload_default_payment_method
133
163
 
134
164
  pay_payment_method
135
165
  end
136
166
 
137
- def update_email!
138
- ::Stripe::Customer.update(processor_id, {email: email, name: customer_name}, stripe_options)
139
- end
140
-
141
167
  def processor_subscription(subscription_id, options = {})
142
168
  ::Stripe::Subscription.retrieve(options.merge(id: subscription_id), stripe_options)
143
169
  end
@@ -151,8 +177,11 @@ module Pay
151
177
  ::Stripe::Invoice.upcoming({customer: processor_id}, stripe_options)
152
178
  end
153
179
 
154
- def create_setup_intent
155
- ::Stripe::SetupIntent.create({customer: processor_id, usage: :off_session}, stripe_options)
180
+ def create_setup_intent(options = {})
181
+ ::Stripe::SetupIntent.create({
182
+ customer: processor_id,
183
+ usage: :off_session
184
+ }.merge(options), stripe_options)
156
185
  end
157
186
 
158
187
  def trial_end_date(stripe_sub)
@@ -184,7 +213,6 @@ module Pay
184
213
  customer unless processor_id?
185
214
  args = {
186
215
  customer: processor_id,
187
- payment_method_types: ["card"],
188
216
  mode: "payment",
189
217
  # These placeholder URLs will be replaced in a following step.
190
218
  success_url: merge_session_id_param(options.delete(:success_url) || root_url),
@@ -239,6 +267,10 @@ module Pay
239
267
  ::Stripe::BillingPortal::Session.create(args.merge(options), stripe_options)
240
268
  end
241
269
 
270
+ def authorize(amount, options = {})
271
+ charge(amount, options.merge(capture_method: :manual))
272
+ end
273
+
242
274
  private
243
275
 
244
276
  # Options for Stripe requests
@@ -3,11 +3,21 @@ module Pay
3
3
  class Charge
4
4
  attr_reader :pay_charge
5
5
 
6
- delegate :processor_id, :stripe_account, to: :pay_charge
6
+ delegate :amount,
7
+ :amount_captured,
8
+ :invoice_id,
9
+ :line_items,
10
+ :payment_intent_id,
11
+ :processor_id,
12
+ :stripe_account,
13
+ to: :pay_charge
7
14
 
8
15
  def self.sync(charge_id, object: nil, stripe_account: nil, try: 0, retries: 1)
9
16
  # Skip loading the latest charge details from the API if we already have it
10
- object ||= ::Stripe::Charge.retrieve(charge_id, {stripe_account: stripe_account}.compact)
17
+ object ||= ::Stripe::Charge.retrieve({id: charge_id, expand: ["invoice.total_discount_amounts.discount", "invoice.total_tax_amounts.tax_rate"]}, {stripe_account: stripe_account}.compact)
18
+
19
+ # Ignore charges without a Customer
20
+ return if object.customer.blank?
11
21
 
12
22
  pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.customer)
13
23
  return unless pay_customer
@@ -15,24 +25,61 @@ module Pay
15
25
  payment_method = object.payment_method_details.send(object.payment_method_details.type)
16
26
  attrs = {
17
27
  amount: object.amount,
28
+ amount_captured: object.amount_captured,
18
29
  amount_refunded: object.amount_refunded,
19
30
  application_fee_amount: object.application_fee_amount,
31
+ bank: payment_method.try(:bank_name) || payment_method.try(:bank), # eps, fpx, ideal, p24, acss_debit, etc
32
+ brand: payment_method.try(:brand)&.capitalize,
20
33
  created_at: Time.at(object.created),
21
34
  currency: object.currency,
22
- stripe_account: pay_customer.stripe_account,
23
- metadata: object.metadata,
24
- payment_method_type: object.payment_method_details.type,
25
- brand: payment_method.try(:brand)&.capitalize,
26
- last4: payment_method.try(:last4).to_s,
35
+ discounts: [],
27
36
  exp_month: payment_method.try(:exp_month).to_s,
28
37
  exp_year: payment_method.try(:exp_year).to_s,
29
- bank: payment_method.try(:bank_name) || payment_method.try(:bank) # eps, fpx, ideal, p24, acss_debit, etc
38
+ last4: payment_method.try(:last4).to_s,
39
+ line_items: [],
40
+ metadata: object.metadata,
41
+ payment_intent_id: object.payment_intent,
42
+ payment_method_type: object.payment_method_details.type,
43
+ stripe_account: pay_customer.stripe_account,
44
+ stripe_receipt_url: object.receipt_url,
45
+ total_tax_amounts: []
30
46
  }
31
47
 
32
48
  # Associate charge with subscription if we can
33
49
  if object.invoice
34
- invoice = (object.invoice.is_a?(::Stripe::Invoice) ? object.invoice : ::Stripe::Invoice.retrieve(object.invoice, {stripe_account: stripe_account}.compact))
50
+ invoice = (object.invoice.is_a?(::Stripe::Invoice) ? object.invoice : ::Stripe::Invoice.retrieve({id: object.invoice, expand: ["total_discount_amounts.discount", "total_tax_amounts.tax_rate"]}, {stripe_account: stripe_account}.compact))
51
+ attrs[:invoice_id] = invoice.id
35
52
  attrs[:subscription] = pay_customer.subscriptions.find_by(processor_id: invoice.subscription)
53
+
54
+ attrs[:period_start] = Time.at(invoice.period_start)
55
+ attrs[:period_end] = Time.at(invoice.period_end)
56
+ attrs[:subtotal] = invoice.subtotal
57
+ attrs[:tax] = invoice.tax
58
+ attrs[:discounts] = invoice.discounts
59
+ attrs[:total_tax_amounts] = invoice.total_tax_amounts.map(&:to_hash)
60
+ attrs[:total_discount_amounts] = invoice.total_discount_amounts.map(&:to_hash)
61
+
62
+ invoice.lines.auto_paging_each do |line_item|
63
+ # Currency is tied to the charge, so storing it would be duplication
64
+ attrs[:line_items] << {
65
+ id: line_item.id,
66
+ description: line_item.description,
67
+ price_id: line_item.price&.id,
68
+ quantity: line_item.quantity,
69
+ unit_amount: line_item.price&.unit_amount,
70
+ amount: line_item.amount,
71
+ discounts: line_item.discounts,
72
+ tax_amounts: line_item.tax_amounts,
73
+ proration: line_item.proration,
74
+ period_start: Time.at(line_item.period.start),
75
+ period_end: Time.at(line_item.period.end)
76
+ }
77
+ end
78
+
79
+ # Charges without invoices
80
+ else
81
+ attrs[:period_start] = Time.at(object.created)
82
+ attrs[:period_end] = Time.at(object.created)
36
83
  end
37
84
 
38
85
  # Update or create the charge
@@ -64,14 +111,49 @@ module Pay
64
111
  raise Pay::Stripe::Error, e
65
112
  end
66
113
 
114
+ # Issues a CreditNote if there's an invoice, otherwise uses a Refund
115
+ # This allows Tax to be handled properly
116
+ #
117
+ # https://stripe.com/docs/api/credit_notes/create
67
118
  # https://stripe.com/docs/api/refunds/create
68
119
  #
69
120
  # refund!
70
121
  # refund!(5_00)
71
122
  # refund!(5_00, refund_application_fee: true)
72
123
  def refund!(amount_to_refund, **options)
73
- ::Stripe::Refund.create(options.merge(charge: processor_id, amount: amount_to_refund), stripe_options)
74
- pay_charge.update(amount_refunded: amount_to_refund)
124
+ if invoice_id.present?
125
+ description = options.delete(:description) || I18n.t("refund")
126
+ lines = [{type: :custom_line_item, description: description, quantity: 1, unit_amount: amount_to_refund}]
127
+ credit_note!(**options.merge(refund_amount: amount_to_refund, lines: lines))
128
+ else
129
+ ::Stripe::Refund.create(options.merge(charge: processor_id, amount: amount_to_refund), stripe_options)
130
+ end
131
+ pay_charge.update!(amount_refunded: pay_charge.amount_refunded + amount_to_refund)
132
+ rescue ::Stripe::StripeError => e
133
+ raise Pay::Stripe::Error, e
134
+ end
135
+
136
+ # Adds a credit note to a Stripe Invoice
137
+ def credit_note!(**options)
138
+ raise Pay::Stripe::Error, "no Stripe invoice_id on Pay::Charge" if invoice_id.blank?
139
+ ::Stripe::CreditNote.create({invoice: invoice_id}.merge(options), stripe_options)
140
+ rescue ::Stripe::StripeError => e
141
+ raise Pay::Stripe::Error, e
142
+ end
143
+
144
+ def credit_notes(**options)
145
+ raise Pay::Stripe::Error, "no Stripe invoice_id on Pay::Charge" if invoice_id.blank?
146
+ ::Stripe::CreditNote.list({invoice: invoice_id}.merge(options), stripe_options)
147
+ end
148
+
149
+ # https://stripe.com/docs/payments/capture-later
150
+ #
151
+ # capture
152
+ # capture(amount_to_capture: 15_00)
153
+ def capture(**options)
154
+ raise Pay::Stripe::Error, "no payment_intent_id on charge" unless payment_intent_id.present?
155
+ ::Stripe::PaymentIntent.capture(payment_intent_id, options, stripe_options)
156
+ self.class.sync(processor_id)
75
157
  rescue ::Stripe::StripeError => e
76
158
  raise Pay::Stripe::Error, e
77
159
  end
@@ -38,7 +38,7 @@ module Pay
38
38
  refresh_url: refresh_url,
39
39
  return_url: return_url,
40
40
  type: type
41
- })
41
+ }.merge(options))
42
42
  rescue ::Stripe::StripeError => e
43
43
  raise Pay::Stripe::Error, e
44
44
  end
@@ -1,6 +1,7 @@
1
1
  module Pay
2
2
  module Stripe
3
3
  class Subscription
4
+ attr_accessor :stripe_subscription
4
5
  attr_reader :pay_subscription
5
6
 
6
7
  delegate :active?,
@@ -16,26 +17,46 @@ module Pay
16
17
  :quantity,
17
18
  :quantity?,
18
19
  :stripe_account,
20
+ :subscription_items,
19
21
  :trial_ends_at,
22
+ :pause_behavior,
23
+ :pause_resumes_at,
20
24
  to: :pay_subscription
21
25
 
22
- def self.sync(subscription_id, object: nil, name: Pay.default_product_name, stripe_account: nil, try: 0, retries: 1)
26
+ def self.sync(subscription_id, object: nil, name: nil, stripe_account: nil, try: 0, retries: 1)
23
27
  # Skip loading the latest subscription details from the API if we already have it
24
- object ||= ::Stripe::Subscription.retrieve({id: subscription_id, expand: ["pending_setup_intent", "latest_invoice.payment_intent", "latest_invoice.charge.invoice"]}, {stripe_account: stripe_account}.compact)
28
+ object ||= ::Stripe::Subscription.retrieve({id: subscription_id}.merge(expand_options), {stripe_account: stripe_account}.compact)
25
29
 
26
30
  pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.customer)
27
31
  return unless pay_customer
28
32
 
29
33
  attributes = {
30
34
  application_fee_percent: object.application_fee_percent,
31
- processor_plan: object.plan.id,
32
- quantity: object.quantity,
35
+ processor_plan: object.items.first.price.id,
36
+ quantity: object.items.first.try(:quantity) || 0,
33
37
  status: object.status,
34
38
  stripe_account: pay_customer.stripe_account,
35
- trial_ends_at: (object.trial_end ? Time.at(object.trial_end) : nil),
36
- metadata: object.metadata
39
+ metadata: object.metadata,
40
+ subscription_items: [],
41
+ metered: false,
42
+ pause_behavior: object.pause_collection&.behavior,
43
+ pause_resumes_at: (object.pause_collection&.resumes_at ? Time.at(object.pause_collection&.resumes_at) : nil)
37
44
  }
38
45
 
46
+ # Subscriptions that have ended should have their trial ended at the same time
47
+ if object.trial_end
48
+ attributes[:trial_ends_at] = Time.at(object.ended_at || object.trial_end)
49
+ end
50
+
51
+ # Record subscription items to db
52
+ object.items.auto_paging_each do |subscription_item|
53
+ if !attributes[:metered] && (subscription_item.to_hash.dig(:price, :recurring, :usage_type) == "metered")
54
+ attributes[:metered] = true
55
+ end
56
+
57
+ attributes[:subscription_items] << subscription_item.to_hash.slice(:id, :price, :metadata, :quantity)
58
+ end
59
+
39
60
  attributes[:ends_at] = if object.ended_at
40
61
  # Fully cancelled subscription
41
62
  Time.at(object.ended_at)
@@ -52,9 +73,15 @@ module Pay
52
73
  if pay_subscription
53
74
  pay_subscription.with_lock { pay_subscription.update!(attributes) }
54
75
  else
76
+ # Allow setting the subscription name in metadata, otherwise use the default
77
+ name ||= object.metadata["pay_name"] || Pay.default_product_name
78
+
55
79
  pay_subscription = pay_customer.subscriptions.create!(attributes.merge(name: name, processor_id: object.id))
56
80
  end
57
81
 
82
+ # Cache the Stripe subscription on the Pay::Subscription that we return
83
+ pay_subscription.stripe_subscription = object
84
+
58
85
  # Sync the latest charge if we already have it loaded (like during subscrbe), otherwise, let webhooks take care of creating it
59
86
  if (charge = object.try(:latest_invoice).try(:charge)) && charge.try(:status) == "succeeded"
60
87
  Pay::Stripe::Charge.sync(charge.id, object: charge)
@@ -71,32 +98,61 @@ module Pay
71
98
  end
72
99
  end
73
100
 
101
+ # Common expand options for all requests that create, retrieve, or update a Stripe Subscription
102
+ def self.expand_options
103
+ {expand: ["pending_setup_intent", "latest_invoice.payment_intent", "latest_invoice.charge.invoice"]}
104
+ end
105
+
74
106
  def initialize(pay_subscription)
75
107
  @pay_subscription = pay_subscription
76
108
  end
77
109
 
78
110
  def subscription(**options)
79
111
  options[:id] = processor_id
80
- options[:expand] ||= ["pending_setup_intent", "latest_invoice.payment_intent", "latest_invoice.charge.invoice"]
81
- ::Stripe::Subscription.retrieve(options, {stripe_account: stripe_account}.compact)
112
+ @stripe_subscription ||= ::Stripe::Subscription.retrieve(options.merge(expand_options), {stripe_account: stripe_account}.compact)
82
113
  end
83
114
 
84
- def cancel
85
- stripe_sub = ::Stripe::Subscription.update(processor_id, {cancel_at_period_end: true}, stripe_options)
86
- pay_subscription.update(ends_at: (on_trial? ? trial_ends_at : Time.at(stripe_sub.current_period_end)))
115
+ def reload!
116
+ @stripe_subscription = nil
117
+ end
118
+
119
+ # Returns a SetupIntent or PaymentIntent client secret for the subscription
120
+ def client_secret
121
+ stripe_sub = subscription
122
+ stripe_sub&.pending_setup_intent&.client_secret || stripe_sub&.latest_invoice&.payment_intent&.client_secret
123
+ end
124
+
125
+ def cancel(**options)
126
+ @stripe_subscription = ::Stripe::Subscription.update(processor_id, {cancel_at_period_end: true}.merge(expand_options), stripe_options)
127
+ pay_subscription.update(ends_at: (on_trial? ? trial_ends_at : Time.at(@stripe_subscription.current_period_end)))
87
128
  rescue ::Stripe::StripeError => e
88
129
  raise Pay::Stripe::Error, e
89
130
  end
90
131
 
91
- def cancel_now!
92
- ::Stripe::Subscription.delete(processor_id, {}, stripe_options)
132
+ # Cancels a subscription immediately
133
+ #
134
+ # cancel_now!(prorate: true)
135
+ # cancel_now!(invoice_now: true)
136
+ def cancel_now!(**options)
137
+ @stripe_subscription = ::Stripe::Subscription.delete(processor_id, options.merge(expand_options), stripe_options)
93
138
  pay_subscription.update(ends_at: Time.current, status: :canceled)
94
139
  rescue ::Stripe::StripeError => e
95
140
  raise Pay::Stripe::Error, e
96
141
  end
97
142
 
98
- def change_quantity(quantity)
99
- ::Stripe::Subscription.update(processor_id, {quantity: quantity}, stripe_options)
143
+ # This updates a SubscriptionItem's quantity in Stripe
144
+ #
145
+ # For a subscription with a single item, we can update the subscription directly if no SubscriptionItem ID is available
146
+ # Otherwise a SubscriptionItem ID is required so Stripe knows which entry to update
147
+ def change_quantity(quantity, **options)
148
+ subscription_item_id = options.fetch(:subscription_item_id, subscription_items.first["id"])
149
+ if subscription_item_id
150
+ ::Stripe::SubscriptionItem.update(subscription_item_id, options.merge(quantity: quantity), stripe_options)
151
+ @stripe_subscription = nil
152
+ else
153
+ @stripe_subscription = ::Stripe::Subscription.update(processor_id, options.merge(quantity: quantity).merge(expand_options), stripe_options)
154
+ end
155
+ true
100
156
  rescue ::Stripe::StripeError => e
101
157
  raise Pay::Stripe::Error, e
102
158
  end
@@ -106,27 +162,47 @@ module Pay
106
162
  end
107
163
 
108
164
  def paused?
109
- false
165
+ pause_behavior.present?
110
166
  end
111
167
 
112
- def pause
113
- raise NotImplementedError, "Stripe does not support pausing subscriptions"
168
+ # Pauses a Stripe subscription
169
+ #
170
+ # pause(behavior: "mark_uncollectible")
171
+ # pause(behavior: "keep_as_draft")
172
+ # pause(behavior: "void")
173
+ # pause(behavior: "mark_uncollectible", resumes_at: 1.month.from_now)
174
+ def pause(**options)
175
+ attributes = {pause_collection: options.reverse_merge(behavior: "mark_uncollectible")}
176
+ @stripe_subscription = ::Stripe::Subscription.update(processor_id, attributes.merge(expand_options), stripe_options)
177
+ pay_subscription.update(
178
+ pause_behavior: @stripe_subscription.pause_collection&.behavior,
179
+ pause_resumes_at: (@stripe_subscription.pause_collection&.resumes_at ? Time.at(@stripe_subscription.pause_collection&.resumes_at) : nil)
180
+ )
181
+ end
182
+
183
+ def unpause
184
+ @stripe_subscription = ::Stripe::Subscription.update(processor_id, {pause_collection: nil}.merge(expand_options), stripe_options)
185
+ pay_subscription.update(pause_behavior: nil, pause_resumes_at: nil)
114
186
  end
115
187
 
116
188
  def resume
117
- unless on_grace_period?
189
+ unless on_grace_period? || paused?
118
190
  raise StandardError, "You can only resume subscriptions within their grace period."
119
191
  end
120
192
 
121
- ::Stripe::Subscription.update(
122
- processor_id,
123
- {
124
- plan: processor_plan,
125
- trial_end: (on_trial? ? trial_ends_at.to_i : "now"),
126
- cancel_at_period_end: false
127
- },
128
- stripe_options
129
- )
193
+ if paused?
194
+ unpause
195
+ else
196
+ @stripe_subscription = ::Stripe::Subscription.update(
197
+ processor_id,
198
+ {
199
+ plan: processor_plan,
200
+ trial_end: (on_trial? ? trial_ends_at.to_i : "now"),
201
+ cancel_at_period_end: false
202
+ }.merge(expand_options),
203
+ stripe_options
204
+ )
205
+ end
130
206
  rescue ::Stripe::StripeError => e
131
207
  raise Pay::Stripe::Error, e
132
208
  end
@@ -134,7 +210,7 @@ module Pay
134
210
  def swap(plan)
135
211
  raise ArgumentError, "plan must be a string" unless plan.is_a?(String)
136
212
 
137
- ::Stripe::Subscription.update(
213
+ @stripe_subscription = ::Stripe::Subscription.update(
138
214
  processor_id,
139
215
  {
140
216
  cancel_at_period_end: false,
@@ -142,19 +218,45 @@ module Pay
142
218
  proration_behavior: (prorate ? "create_prorations" : "none"),
143
219
  trial_end: (on_trial? ? trial_ends_at.to_i : "now"),
144
220
  quantity: quantity
145
- },
221
+ }.merge(expand_options),
146
222
  stripe_options
147
223
  )
148
224
  rescue ::Stripe::StripeError => e
149
225
  raise Pay::Stripe::Error, e
150
226
  end
151
227
 
228
+ # Creates a metered billing usage record
229
+ #
230
+ # Uses the first subscription_item ID unless `subscription_item_id: "si_1234"` is passed
231
+ #
232
+ # create_usage_record(quantity: 4, action: :increment)
233
+ # create_usage_record(subscription_item_id: "si_1234", quantity: 100, action: :set)
234
+ def create_usage_record(**options)
235
+ subscription_item_id = options.fetch(:subscription_item_id, subscription_items.first["id"])
236
+ ::Stripe::SubscriptionItem.create_usage_record(subscription_item_id, options, stripe_options)
237
+ end
238
+
239
+ # Returns usage record summaries for a subscription item
240
+ def usage_record_summaries(**options)
241
+ subscription_item_id = options.fetch(:subscription_item_id, subscription_items.first["id"])
242
+ ::Stripe::SubscriptionItem.list_usage_record_summaries(subscription_item_id, options, stripe_options)
243
+ end
244
+
245
+ # Returns an upcoming invoice for a subscription
246
+ def upcoming_invoice(**options)
247
+ ::Stripe::Invoice.upcoming(options.merge(subscription: processor_id), stripe_options)
248
+ end
249
+
152
250
  private
153
251
 
154
252
  # Options for Stripe requests
155
253
  def stripe_options
156
254
  {stripe_account: stripe_account}.compact
157
255
  end
256
+
257
+ def expand_options
258
+ self.class.expand_options
259
+ end
158
260
  end
159
261
  end
160
262
  end
@@ -5,8 +5,8 @@ module Pay
5
5
  def call(event)
6
6
  pay_charge = Pay::Stripe::Charge.sync(event.data.object.id, stripe_account: event.try(:account))
7
7
 
8
- if pay_charge && Pay.send_emails
9
- Pay::UserMailer.with(pay_customer: pay_charge.customer, charge: pay_charge).refund.deliver_later
8
+ if pay_charge && Pay.send_email?(:refund, pay_charge)
9
+ Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).refund.deliver_later
10
10
  end
11
11
  end
12
12
  end
@@ -5,8 +5,8 @@ module Pay
5
5
  def call(event)
6
6
  pay_charge = Pay::Stripe::Charge.sync(event.data.object.id, stripe_account: event.try(:account))
7
7
 
8
- if pay_charge && Pay.send_emails
9
- Pay::UserMailer.with(pay_customer: pay_charge.customer, charge: pay_charge).receipt.deliver_later
8
+ if pay_charge && Pay.send_email?(:receipt, pay_charge)
9
+ Pay.mailer.with(pay_customer: pay_charge.customer, pay_charge: pay_charge).receipt.deliver_later
10
10
  end
11
11
  end
12
12
  end
@@ -1,14 +1,7 @@
1
1
  module Pay
2
2
  module Stripe
3
3
  module Webhooks
4
- class CheckoutSessionAsyncPaymentSucceeded
5
- def call(event)
6
- # TODO: Also handle payment intents
7
-
8
- if event.data.object.subscription
9
- Pay::Stripe::Subscription.sync(event.data.object.subscription, stripe_account: event.try(:account))
10
- end
11
- end
4
+ class CheckoutSessionAsyncPaymentSucceeded < CheckoutSessionCompleted
12
5
  end
13
6
  end
14
7
  end
@@ -5,10 +5,29 @@ module Pay
5
5
  def call(event)
6
6
  # TODO: Also handle payment intents
7
7
 
8
- if event.data.object.subscription
9
- Pay::Stripe::Subscription.sync(event.data.object.subscription, stripe_account: event.try(:account))
8
+ locate_owner(event.data.object)
9
+
10
+ if (payment_intent_id = event.data.object.payment_intent)
11
+ payment_intent = ::Stripe::PaymentIntent.retrieve(payment_intent_id, {stripe_account: event.try(:account)}.compact)
12
+ payment_intent.charges.each do |charge|
13
+ Pay::Stripe::Charge.sync(charge.id, stripe_account: event.try(:account))
14
+ end
15
+ end
16
+
17
+ if (subscription_id = event.data.object.subscription)
18
+ Pay::Stripe::Subscription.sync(subscription_id, stripe_account: event.try(:account))
10
19
  end
11
20
  end
21
+
22
+ def locate_owner(object)
23
+ return if object.client_reference_id.nil?
24
+
25
+ # If there is a client reference ID, make sure we have a Pay::Customer record
26
+ owner = GlobalID::Locator.locate_signed(object.client_reference_id)
27
+ owner&.add_payment_processor(:stripe, processor_id: object.customer)
28
+ rescue
29
+ Rails.logger.debug "[Pay] Unable to locate record with SGID: #{object.client_reference_id}"
30
+ end
12
31
  end
13
32
  end
14
33
  end
@@ -8,14 +8,14 @@ module Pay
8
8
 
9
9
  object = event.data.object
10
10
 
11
- subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.subscription)
12
- return if subscription.nil?
11
+ pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.subscription)
12
+ return if pay_subscription.nil?
13
13
 
14
- if Pay.send_emails
15
- Pay::UserMailer.with(
16
- pay_customer: subscription.customer,
14
+ if Pay.send_email?(:payment_action_required, pay_subscription)
15
+ Pay.mailer.with(
16
+ pay_customer: pay_subscription.customer,
17
17
  payment_intent_id: event.data.object.payment_intent,
18
- subscription: subscription
18
+ pay_subscription: pay_subscription
19
19
  ).payment_action_required.deliver_later
20
20
  end
21
21
  end