pay 5.0.3 → 6.0.0

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.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c607f5486ae4c1dfbf46b492e1a369e89e763e03e43bb25596882396285a416a
4
- data.tar.gz: cbe12e5104c4cc18c9edc0a3d2ecf99f2d9d3adad431e4868747625f4934e6ec
3
+ metadata.gz: 46dc11ef6e994fd5e55708294d2ffcd1a889bcd58b2a2a6473ee12bee883fb95
4
+ data.tar.gz: 7f7df60a4318e91cd11d616f744d733cb756a4cac21c56a66031026a539e3cd8
5
5
  SHA512:
6
- metadata.gz: ae0b3f32f96f2efcc5618df5b158397e1d6d1c6c79ee2109bb806628da2c53d998d8090fb7c9d7b2055a7ba660960b8ae4a36a6e6c4da54a943b4cd7020adae2
7
- data.tar.gz: 356ba70d14722f9b083f366a957c26809082ce14193d7e4ec09350cf4c232de8dd215736b8c5968166d5659a8d9235df90104cc9367654b94e8905a09cfcdb77
6
+ metadata.gz: 9dd6da70a8e3aceb1b8ea1a6010bf2cfc2034953ed56a9a197dc9685afa9a1f9b26960072f5ae0590359b24516988f61a82f0567f9443462cdbc6bf03dc75d08
7
+ data.tar.gz: 1910bbf68cebf716348b0a1a7ad8e639ea14c86a90c1a0abf04eae037a3370a5ff2b6ffc7c4049af03a900e21e7c41a0b29deb729aeb67d8378dcb79f94cbbac
@@ -5,25 +5,33 @@ module Pay
5
5
  attachments[params[:pay_charge].filename] = params[:pay_charge].receipt
6
6
  end
7
7
 
8
- mail to: to
8
+ mail mail_arguments
9
9
  end
10
10
 
11
11
  def refund
12
- mail to: to
12
+ mail mail_arguments
13
13
  end
14
14
 
15
15
  def subscription_renewing
16
- mail to: to
16
+ mail mail_arguments
17
17
  end
18
18
 
19
19
  def payment_action_required
20
- mail to: to
20
+ mail mail_arguments
21
+ end
22
+
23
+ def subscription_trial_will_end
24
+ mail mail_arguments
25
+ end
26
+
27
+ def subscription_trial_ended
28
+ mail mail_arguments
21
29
  end
22
30
 
23
31
  private
24
32
 
25
- def to
26
- "#{params[:pay_customer].customer_name} <#{params[:pay_customer].email}>"
33
+ def mail_arguments
34
+ Pay.mail_arguments.call(mailer_name, params)
27
35
  end
28
36
  end
29
37
  end
@@ -46,7 +46,7 @@ module Pay
46
46
  end
47
47
 
48
48
  def subscription(name: Pay.default_product_name)
49
- subscriptions.loaded? ? subscriptions.reverse.detect { |s| s.name == name } : subscriptions.for_name(name).last
49
+ subscriptions.order(id: :desc).for_name(name).first
50
50
  end
51
51
 
52
52
  def subscribed?(name: Pay.default_product_name, processor_plan: nil)
@@ -92,5 +92,10 @@ module Pay
92
92
  # If these match, consider it a generic trial
93
93
  subscription.trial_ends_at == subscription.ends_at
94
94
  end
95
+
96
+ # Attempts to pay all past_due subscriptions
97
+ def retry_past_due_subscriptions!
98
+ subscriptions.past_due.each(&:retry_failed_payment)
99
+ end
95
100
  end
96
101
  end
@@ -8,14 +8,16 @@ module Pay
8
8
 
9
9
  # Scopes
10
10
  scope :for_name, ->(name) { where(name: name) }
11
- scope :on_trial, -> { where.not(trial_ends_at: nil).where("#{table_name}.trial_ends_at > ?", Time.zone.now) }
11
+ scope :on_trial, -> { where.not(trial_ends_at: nil).where("#{table_name}.trial_ends_at > ?", Time.current) }
12
12
  scope :cancelled, -> { where.not(ends_at: nil) }
13
- scope :on_grace_period, -> { cancelled.where("#{table_name}.ends_at > ?", Time.zone.now) }
14
- # Stripe considers paused subscriptions to be active, therefore we reflect that in this scope and
15
- # make it consistent across all processors
16
- scope :active, -> { where(status: ["trialing", "active", "paused"], ends_at: nil).or(on_grace_period).or(on_trial) }
13
+ scope :on_grace_period, -> { cancelled.where("#{table_name}.ends_at > ?", Time.current) }
14
+ scope :active, -> { where(status: ["trialing", "active"], ends_at: nil).pause_not_started.or(on_grace_period).or(on_trial) }
15
+ scope :paused, -> { where(status: "paused").or(where("pause_starts_at <= ?", Time.current)) }
16
+ scope :pause_not_started, -> { where("pause_starts_at IS NULL OR pause_starts_at > ?", Time.current) }
17
+ scope :active_or_paused, -> { active.or(paused) }
17
18
  scope :incomplete, -> { where(status: :incomplete) }
18
19
  scope :past_due, -> { where(status: :past_due) }
20
+ scope :metered, -> { where(metered: true) }
19
21
  scope :with_active_customer, -> { joins(:customer).merge(Customer.active) }
20
22
  scope :with_deleted_customer, -> { joins(:customer).merge(Customer.deleted) }
21
23
 
@@ -24,12 +26,8 @@ module Pay
24
26
 
25
27
  store_accessor :data, :paddle_update_url
26
28
  store_accessor :data, :paddle_cancel_url
27
- store_accessor :data, :paddle_paused_from
28
29
  store_accessor :data, :stripe_account
29
- store_accessor :data, :metered
30
30
  store_accessor :data, :subscription_items
31
- store_accessor :data, :pause_behavior
32
- store_accessor :data, :pause_resumes_at
33
31
 
34
32
  attribute :prorate, :boolean, default: true
35
33
 
@@ -51,36 +49,6 @@ module Pay
51
49
  scope processor_name, -> { joins(:customer).where(pay_customers: {processor: processor_name}) }
52
50
  end
53
51
 
54
- def self.active_without_paused
55
- case Pay::Adapter.current_adapter
56
- when "postgresql", "postgis"
57
- active.where("data->>'pause_behavior' IS NULL AND status != 'paused'")
58
- when "mysql2"
59
- active.where("data->>'$.pause_behavior' IS NULL AND status != 'paused'")
60
- when "sqlite3"
61
- # sqlite 3.38 supports ->> syntax, however, sqlite 3.37 is what ships with Ubuntu 22.04.
62
- active.where("json_extract(data, '$.pause_behavior') IS NULL AND status != 'paused'")
63
- end
64
- end
65
-
66
- def self.with_metered_items
67
- case Pay::Adapter.current_adapter
68
- when "sqlite3"
69
- where("json_extract(data, '$.\"metered\"') = true")
70
- # For SQLite 3.38+ we could use the arrows
71
- # where("data->'metered' = ?", "true")
72
- when "mysql2"
73
- where("data->'$.\"metered\"' = true")
74
- when "postgresql", "postgis"
75
- # Single quotes are important for json keys apparently
76
- where("data->>'metered' = 'true'")
77
- end
78
- end
79
-
80
- def metered_items?
81
- !!metered
82
- end
83
-
84
52
  def self.find_by_processor_and_id(processor, processor_id)
85
53
  joins(:customer).find_by(processor_id: processor_id, pay_customers: {processor: processor})
86
54
  end
@@ -93,8 +61,9 @@ module Pay
93
61
  @payment_processor ||= self.class.pay_processor_for(customer.processor).new(self)
94
62
  end
95
63
 
96
- def sync!
97
- self.class.pay_processor_for(customer.processor).sync(processor_id)
64
+ def sync!(**options)
65
+ self.class.pay_processor_for(customer.processor).sync(processor_id, **options)
66
+ reload
98
67
  end
99
68
 
100
69
  def no_prorate
@@ -109,8 +78,16 @@ module Pay
109
78
  fake_processor? && trial_ends_at?
110
79
  end
111
80
 
81
+ def has_trial?
82
+ trial_ends_at?
83
+ end
84
+
112
85
  def on_trial?
113
- trial_ends_at? && Time.zone.now < trial_ends_at
86
+ trial_ends_at? && trial_ends_at.after?(Time.current)
87
+ end
88
+
89
+ def trial_ended?
90
+ trial_ends_at? && trial_ends_at.before?(Time.current)
114
91
  end
115
92
 
116
93
  def canceled?
@@ -121,8 +98,21 @@ module Pay
121
98
  canceled?
122
99
  end
123
100
 
101
+ def on_grace_period?
102
+ (ends_at? && Time.current < ends_at) ||
103
+ ((status == "paused" || pause_behavior == "void") && will_pause?)
104
+ end
105
+
106
+ def will_pause?
107
+ pause_starts_at? && Time.current < pause_starts_at
108
+ end
109
+
110
+ def pause_active?
111
+ (status == "paused" || pause_behavior == "void") && (pause_starts_at.nil? || pause_starts_at >= Time.current)
112
+ end
113
+
124
114
  def active?
125
- ["trialing", "active", "paused"].include?(status) && (ends_at.nil? || on_grace_period? || on_trial?)
115
+ ["trialing", "active"].include?(status) && (!(canceled? || paused?) || on_trial? || on_grace_period?)
126
116
  end
127
117
 
128
118
  def past_due?
@@ -137,8 +127,8 @@ module Pay
137
127
  past_due? || incomplete?
138
128
  end
139
129
 
140
- def change_quantity(quantity)
141
- payment_processor.change_quantity(quantity)
130
+ def change_quantity(quantity, **options)
131
+ payment_processor.change_quantity(quantity, **options)
142
132
  update(quantity: quantity)
143
133
  end
144
134
 
@@ -148,10 +138,9 @@ module Pay
148
138
  self
149
139
  end
150
140
 
151
- def swap(plan)
141
+ def swap(plan, **options)
152
142
  raise ArgumentError, "plan must be a string. Got `#{plan.inspect}` instead." unless plan.is_a?(String)
153
- payment_processor.swap(plan)
154
- update(processor_plan: plan, ends_at: nil, status: :active)
143
+ payment_processor.swap(plan, **options)
155
144
  end
156
145
 
157
146
  def swap_and_invoice(plan)
@@ -167,18 +156,6 @@ module Pay
167
156
  processor_subscription(expand: ["latest_invoice.payment_intent"]).latest_invoice.payment_intent
168
157
  end
169
158
 
170
- def paddle_paused_from
171
- if (timestamp = super)
172
- Time.zone.parse(timestamp)
173
- end
174
- end
175
-
176
- def pause_resumes_at
177
- if (resumes_at = super)
178
- Time.zone.parse(resumes_at)
179
- end
180
- end
181
-
182
159
  private
183
160
 
184
161
  def cancel_if_active
@@ -0,0 +1,6 @@
1
+ <h3>Your <%= Pay.business_name %> trial has ended</h3>
2
+ <p>This is just a friendly reminder that your <%= Pay.business_name %> trial has ended.</p>
3
+
4
+ <p>You may manage your subscription via your account. If you have any questions, please hit reply and let us know.</p>
5
+
6
+ <p>- The <%= Pay.business_name %> Team</p>
@@ -0,0 +1,6 @@
1
+ <h3>Your <%= Pay.business_name %> trial is ending soon</h3>
2
+ <p>This is just a friendly reminder that your <%= Pay.business_name %> trial will be ending soon.</p>
3
+
4
+ <p>You may manage your subscription via your account. If you have any questions, please hit reply and let us know.</p>
5
+
6
+ <p>- The <%= Pay.business_name %> Team</p>
@@ -56,3 +56,7 @@ en:
56
56
  subject: "Your upcoming subscription renewal"
57
57
  payment_action_required:
58
58
  subject: "Confirm your payment"
59
+ subscription_trial_will_end:
60
+ subject: "Your trial is ending soon"
61
+ subscription_trial_ended:
62
+ subject: "Your trial has ended"
@@ -39,14 +39,22 @@ class CreatePayTables < ActiveRecord::Migration[6.0]
39
39
  t.string :processor_plan, null: false
40
40
  t.integer :quantity, default: 1, null: false
41
41
  t.string :status, null: false
42
+ t.datetime :current_period_start
43
+ t.datetime :current_period_end
42
44
  t.datetime :trial_ends_at
43
45
  t.datetime :ends_at
46
+ t.boolean :metered
47
+ t.string :pause_behavior
48
+ t.datetime :pause_starts_at
49
+ t.datetime :pause_resumes_at
44
50
  t.decimal :application_fee_percent, precision: 8, scale: 2
45
51
  t.public_send Pay::Adapter.json_column_type, :metadata
46
52
  t.public_send Pay::Adapter.json_column_type, :data
47
53
  t.timestamps
48
54
  end
49
55
  add_index :pay_subscriptions, [:customer_id, :processor_id], unique: true
56
+ add_index :pay_subscriptions, [:metered]
57
+ add_index :pay_subscriptions, [:pause_starts_at]
50
58
 
51
59
  create_table :pay_charges do |t|
52
60
  t.belongs_to :customer, foreign_key: {to_table: :pay_customers}, null: false, index: false
@@ -8,7 +8,7 @@ module Pay
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  included do
11
- after_update :enqeue_customer_sync_job, if: :pay_should_sync_customer?
11
+ after_update_commit :enqeue_customer_sync_job, if: :pay_should_sync_customer?
12
12
  end
13
13
 
14
14
  def pay_should_sync_customer?
@@ -4,8 +4,9 @@ module Pay
4
4
  attr_reader :pay_subscription
5
5
 
6
6
  delegate :active?,
7
- :customer,
8
7
  :canceled?,
8
+ :on_grace_period?,
9
+ :customer,
9
10
  :ends_at,
10
11
  :name,
11
12
  :on_trial?,
@@ -55,6 +56,10 @@ module Pay
55
56
  raise Pay::Braintree::Error, e
56
57
  end
57
58
 
59
+ def change_quantity(quantity, **options)
60
+ raise NotImplementedError, "Braintree does not support setting quantity on subscriptions"
61
+ end
62
+
58
63
  def on_grace_period?
59
64
  canceled? && Time.current < ends_at
60
65
  end
@@ -96,7 +101,7 @@ module Pay
96
101
  raise Pay::Braintree::Error, e
97
102
  end
98
103
 
99
- def swap(plan)
104
+ def swap(plan, **options)
100
105
  raise ArgumentError, "plan must be a string" unless plan.is_a?(String)
101
106
 
102
107
  if on_grace_period? && processor_plan == plan
@@ -128,10 +133,25 @@ module Pay
128
133
  }
129
134
  })
130
135
  raise Error, "Braintree failed to swap plans: #{result.message}" unless result.success?
136
+
137
+ pay_subscription.update(processor_plan: plan, ends_at: nil, status: :active)
131
138
  rescue ::Braintree::BraintreeError => e
132
139
  raise Pay::Braintree::Error, e
133
140
  end
134
141
 
142
+ # Retries the latest invoice for a Past Due subscription
143
+ def retry_failed_payment
144
+ result = gateway.subscription.retry_charge(
145
+ processor_id,
146
+ nil, # amount if different
147
+ true # submit for settlement
148
+ )
149
+
150
+ if result.success?
151
+ pay_subscription.update(status: :active)
152
+ end
153
+ end
154
+
135
155
  private
136
156
 
137
157
  def gateway
data/lib/pay/engine.rb CHANGED
@@ -17,7 +17,9 @@ module Pay
17
17
  end
18
18
  end
19
19
 
20
- initializer "pay.webhooks" do
20
+ # Add webhook subscribers before app initializers define extras
21
+ # This keeps the processing in order so that changes have happened before user-defined webhook processors
22
+ config.before_initialize do
21
23
  Pay::Stripe.configure_webhooks if Pay::Stripe.enabled?
22
24
  Pay::Braintree.configure_webhooks if Pay::Braintree.enabled?
23
25
  Pay::Paddle.configure_webhooks if Pay::Paddle.enabled?
@@ -0,0 +1,26 @@
1
+ module Pay
2
+ module FakeProcessor
3
+ class Merchant
4
+ attr_reader :pay_merchant
5
+ delegate :processor_id, to: :pay_merchant
6
+
7
+ def initialize(pay_merchant)
8
+ @pay_merchant = pay_merchant
9
+ end
10
+
11
+ def create_account(**options)
12
+ fake_account = Struct.new(:id).new("fake_account_id")
13
+ pay_merchant.update(processor_id: fake_account.id)
14
+ fake_account
15
+ end
16
+
17
+ def account_link(refresh_url:, return_url:, type: "account_onboarding", **options)
18
+ Struct.new(:url).new("/fake_processor/account_link")
19
+ end
20
+
21
+ def login_link(**options)
22
+ Struct.new(:url).new("/fake_processor/login_link")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -3,9 +3,11 @@ module Pay
3
3
  class Subscription
4
4
  attr_reader :pay_subscription
5
5
 
6
- delegate :canceled?,
7
- :ends_at,
6
+ delegate :active?,
7
+ :canceled?,
8
+ :on_grace_period?,
8
9
  :on_trial?,
10
+ :ends_at,
9
11
  :owner,
10
12
  :processor_subscription,
11
13
  :processor_id,
@@ -42,6 +44,10 @@ module Pay
42
44
  )
43
45
  end
44
46
 
47
+ def change_quantity(quantity, **options)
48
+ pay_subscription.update(quantity: quantity)
49
+ end
50
+
45
51
  def on_grace_period?
46
52
  canceled? && Time.current < ends_at
47
53
  end
@@ -60,10 +66,13 @@ module Pay
60
66
  end
61
67
  end
62
68
 
63
- def swap(plan)
69
+ def swap(plan, **options)
70
+ pay_subscription.update(processor_plan: plan, ends_at: nil, status: :active)
64
71
  end
65
72
 
66
- def change_quantity(quantity)
73
+ # Retries the latest invoice for a Past Due subscription
74
+ def retry_failed_payment
75
+ pay_subscription.update(status: :active)
67
76
  end
68
77
  end
69
78
  end
@@ -5,5 +5,6 @@ module Pay
5
5
  autoload :Error, "pay/fake_processor/error"
6
6
  autoload :PaymentMethod, "pay/fake_processor/payment_method"
7
7
  autoload :Subscription, "pay/fake_processor/subscription"
8
+ autoload :Merchant, "pay/fake_processor/merchant"
8
9
  end
9
10
  end
@@ -5,11 +5,12 @@ module Pay
5
5
 
6
6
  delegate :active?,
7
7
  :canceled?,
8
+ :on_grace_period?,
9
+ :on_trial?,
8
10
  :ends_at,
9
11
  :name,
10
- :on_trial?,
11
12
  :owner,
12
- :paddle_paused_from,
13
+ :pause_starts_at,
13
14
  :processor_id,
14
15
  :processor_plan,
15
16
  :processor_subscription,
@@ -78,7 +79,7 @@ module Pay
78
79
  ends_at = if on_trial?
79
80
  trial_ends_at
80
81
  elsif paused?
81
- paddle_paused_from
82
+ pause_starts_at
82
83
  else
83
84
  processor_subscription.next_payment&.fetch(:date) || Time.current
84
85
  end
@@ -102,6 +103,10 @@ module Pay
102
103
  raise Pay::Paddle::Error, e
103
104
  end
104
105
 
106
+ def change_quantity(quantity, **options)
107
+ raise NotImplementedError, "Paddle does not support setting quantity on subscriptions"
108
+ end
109
+
105
110
  def on_grace_period?
106
111
  canceled? && Time.current < ends_at || paused? && Time.current < paddle_paused_from
107
112
  end
@@ -113,7 +118,7 @@ module Pay
113
118
  def pause
114
119
  attributes = {pause: true}
115
120
  response = PaddlePay::Subscription::User.update(processor_id, attributes)
116
- pay_subscription.update(status: :paused, paddle_paused_from: Time.zone.parse(response.dig(:next_payment, :date)))
121
+ pay_subscription.update(status: :paused, pause_starts_at: Time.zone.parse(response.dig(:next_payment, :date)))
117
122
  rescue ::PaddlePay::PaddlePayError => e
118
123
  raise Pay::Paddle::Error, e
119
124
  end
@@ -125,20 +130,26 @@ module Pay
125
130
 
126
131
  attributes = {pause: false}
127
132
  PaddlePay::Subscription::User.update(processor_id, attributes)
128
- pay_subscription.update(status: :active, paddle_paused_from: nil)
133
+ pay_subscription.update(status: :active, pause_starts_at: nil)
129
134
  rescue ::PaddlePay::PaddlePayError => e
130
135
  raise Pay::Paddle::Error, e
131
136
  end
132
137
 
133
- def swap(plan)
138
+ def swap(plan, **options)
134
139
  raise ArgumentError, "plan must be a string" unless plan.is_a?(String)
135
140
 
136
141
  attributes = {plan_id: plan, prorate: prorate}
137
142
  attributes[:quantity] = quantity if quantity?
138
143
  PaddlePay::Subscription::User.update(processor_id, attributes)
144
+
145
+ pay_subscription.update(processor_plan: plan, ends_at: nil, status: :active)
139
146
  rescue ::PaddlePay::PaddlePayError => e
140
147
  raise Pay::Paddle::Error, e
141
148
  end
149
+
150
+ # Retries the latest invoice for a Past Due subscription
151
+ def retry_failed_payment
152
+ end
142
153
  end
143
154
  end
144
155
  end
@@ -16,7 +16,7 @@ module Pay
16
16
  pay_subscription.trial_ends_at = Time.zone.parse(event["next_bill_date"])
17
17
  when "active"
18
18
  pay_subscription.status = "active"
19
- pay_subscription.paddle_paused_from = Time.zone.parse(event["paused_from"]) if event["paused_from"].present?
19
+ pay_subscription.pause_starts_at = Time.zone.parse(event["paused_from"]) if event["paused_from"].present?
20
20
  else
21
21
  pay_subscription.status = event["status"]
22
22
  end
@@ -57,10 +57,7 @@ module Pay
57
57
  end
58
58
 
59
59
  if payment_method_token?
60
- payment_method = ::Stripe::PaymentMethod.attach(payment_method_token, {customer: stripe_customer.id}, stripe_options)
61
- pay_payment_method = save_payment_method(payment_method, default: false)
62
- pay_payment_method.make_default!
63
-
60
+ add_payment_method(payment_method_token, default: true)
64
61
  pay_customer.payment_method_token = nil
65
62
  end
66
63
 
@@ -89,13 +86,14 @@ module Pay
89
86
  confirm: true,
90
87
  currency: "usd",
91
88
  customer: processor_id,
89
+ expand: ["latest_charge.refunds"],
92
90
  payment_method: payment_method&.processor_id
93
91
  }.merge(options)
94
92
 
95
93
  payment_intent = ::Stripe::PaymentIntent.create(args, stripe_options)
96
94
  Pay::Payment.new(payment_intent).validate
97
95
 
98
- charge = payment_intent.charges.first
96
+ charge = payment_intent.latest_charge
99
97
  Pay::Stripe::Charge.sync(charge.id, object: charge)
100
98
  rescue ::Stripe::StripeError => e
101
99
  raise Pay::Stripe::Error, e
@@ -105,13 +103,9 @@ module Pay
105
103
  quantity = options.delete(:quantity)
106
104
  opts = {
107
105
  expand: ["pending_setup_intent", "latest_invoice.payment_intent", "latest_invoice.charge"],
108
- items: [plan: plan, quantity: quantity],
109
- off_session: true
106
+ items: [plan: plan, quantity: quantity]
110
107
  }.merge(options)
111
108
 
112
- # Inherit trial from plan unless trial override was specified
113
- opts[:trial_from_plan] = true unless opts[:trial_period_days]
114
-
115
109
  # Load the Stripe customer to verify it exists and update payment method if needed
116
110
  opts[:customer] = customer.id
117
111
 
@@ -190,9 +184,10 @@ module Pay
190
184
  stripe_sub.trial_end.present? ? Time.at(stripe_sub.trial_end) : nil
191
185
  end
192
186
 
193
- # Syncs a customer's subscriptions from Stripe to the database
194
- def sync_subscriptions
195
- subscriptions = ::Stripe::Subscription.list({customer: customer}, stripe_options)
187
+ # Syncs a customer's subscriptions from Stripe to the database.
188
+ # Note that by default canceled subscriptions are NOT returned by Stripe. In order to include them, use `sync_subscriptions(status: "all")`.
189
+ def sync_subscriptions(**options)
190
+ subscriptions = ::Stripe::Subscription.list(options.merge(customer: customer), stripe_options)
196
191
  subscriptions.map do |subscription|
197
192
  Pay::Stripe::Subscription.sync(subscription.id)
198
193
  end
@@ -14,7 +14,7 @@ module Pay
14
14
 
15
15
  def self.sync(charge_id, object: nil, stripe_account: nil, try: 0, retries: 1)
16
16
  # Skip loading the latest charge details from the API if we already have it
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)
17
+ object ||= ::Stripe::Charge.retrieve({id: charge_id, expand: ["invoice.total_discount_amounts.discount", "invoice.total_tax_amounts.tax_rate", "refunds"]}, {stripe_account: stripe_account}.compact)
18
18
 
19
19
  # Ignore charges without a Customer
20
20
  return if object.customer.blank?
@@ -9,6 +9,23 @@ module Pay
9
9
  @pay_payment_method = pay_payment_method
10
10
  end
11
11
 
12
+ # Syncs a PaymentIntent's payment method to the database
13
+ def self.sync_payment_intent(id, stripe_account: nil)
14
+ payment_intent = ::Stripe::PaymentIntent.retrieve({id: id, expand: ["payment_method"]}, {stripe_account: stripe_account}.compact)
15
+ payment_method = payment_intent.payment_method
16
+ return unless payment_method
17
+ Pay::Stripe::PaymentMethod.sync(payment_method.id, object: payment_method)
18
+ end
19
+
20
+ # Syncs a SetupIntent's payment method to the database
21
+ def self.sync_setup_intent(id, stripe_account: nil)
22
+ setup_intent = ::Stripe::SetupIntent.retrieve({id: id, expand: ["payment_method"]}, {stripe_account: stripe_account}.compact)
23
+ payment_method = setup_intent.payment_method
24
+ return unless payment_method
25
+ Pay::Stripe::PaymentMethod.sync(payment_method.id, object: payment_method)
26
+ end
27
+
28
+ # Syncs PaymentMethod objects from Stripe
12
29
  def self.sync(id, object: nil, stripe_account: nil, try: 0, retries: 1)
13
30
  object ||= ::Stripe::PaymentMethod.retrieve(id, {stripe_account: stripe_account}.compact)
14
31
 
@@ -6,6 +6,7 @@ module Pay
6
6
 
7
7
  delegate :active?,
8
8
  :canceled?,
9
+ :on_grace_period?,
9
10
  :ends_at,
10
11
  :name,
11
12
  :on_trial?,
@@ -21,6 +22,8 @@ module Pay
21
22
  :trial_ends_at,
22
23
  :pause_behavior,
23
24
  :pause_resumes_at,
25
+ :current_period_start,
26
+ :current_period_end,
24
27
  to: :pay_subscription
25
28
 
26
29
  def self.sync(subscription_id, object: nil, name: nil, stripe_account: nil, try: 0, retries: 1)
@@ -32,6 +35,7 @@ module Pay
32
35
 
33
36
  attributes = {
34
37
  application_fee_percent: object.application_fee_percent,
38
+ created_at: Time.at(object.created),
35
39
  processor_plan: object.items.first.price.id,
36
40
  quantity: object.items.first.try(:quantity) || 0,
37
41
  status: object.status,
@@ -40,12 +44,20 @@ module Pay
40
44
  subscription_items: [],
41
45
  metered: false,
42
46
  pause_behavior: object.pause_collection&.behavior,
43
- pause_resumes_at: (object.pause_collection&.resumes_at ? Time.at(object.pause_collection&.resumes_at) : nil)
47
+ pause_resumes_at: (object.pause_collection&.resumes_at ? Time.at(object.pause_collection&.resumes_at) : nil),
48
+ current_period_start: (object.current_period_start ? Time.at(object.current_period_start) : nil),
49
+ current_period_end: (object.current_period_end ? Time.at(object.current_period_end) : nil)
44
50
  }
45
51
 
46
- # Subscriptions that have ended should have their trial ended at the same time
52
+ # Subscriptions that have ended should have their trial ended at the
53
+ # same time if they were still on trial (if you cancel a
54
+ # subscription, your are cancelling your trial as well at the same
55
+ # instant). This avoids canceled subscriptions responding `true`
56
+ # to #on_trial? due to the `trial_ends_at` being left set in the
57
+ # future.
47
58
  if object.trial_end
48
- attributes[:trial_ends_at] = Time.at(object.ended_at || object.trial_end)
59
+ trial_ended_at = [object.ended_at, object.trial_end].compact.min
60
+ attributes[:trial_ends_at] = Time.at(trial_ended_at)
49
61
  end
50
62
 
51
63
  # Record subscription items to db
@@ -71,11 +83,18 @@ module Pay
71
83
  # Update or create the subscription
72
84
  pay_subscription = pay_customer.subscriptions.find_by(processor_id: object.id)
73
85
  if pay_subscription
86
+ # If pause behavior is changing to `void`, record the pause start date
87
+ # Any other pause status (or no pause at all) should have nil for start
88
+ if pay_subscription.pause_behavior != attributes[:pause_behavior]
89
+ attributes[:pause_starts_at] = if attributes[:pause_behavior] == "void"
90
+ Time.at(object.current_period_end)
91
+ end
92
+ end
93
+
74
94
  pay_subscription.with_lock { pay_subscription.update!(attributes) }
75
95
  else
76
96
  # Allow setting the subscription name in metadata, otherwise use the default
77
97
  name ||= object.metadata["pay_name"] || Pay.default_product_name
78
-
79
98
  pay_subscription = pay_customer.subscriptions.create!(attributes.merge(name: name, processor_id: object.id))
80
99
  end
81
100
 
@@ -153,7 +172,7 @@ module Pay
153
172
  # For a subscription with a single item, we can update the subscription directly if no SubscriptionItem ID is available
154
173
  # Otherwise a SubscriptionItem ID is required so Stripe knows which entry to update
155
174
  def change_quantity(quantity, **options)
156
- subscription_item_id = options.fetch(:subscription_item_id, subscription_items&.first&.dig("id"))
175
+ subscription_item_id = options.delete(:subscription_item_id) || subscription_items&.first&.dig("id")
157
176
  if subscription_item_id
158
177
  ::Stripe::SubscriptionItem.update(subscription_item_id, options.merge(quantity: quantity), stripe_options)
159
178
  @stripe_subscription = nil
@@ -165,12 +184,8 @@ module Pay
165
184
  raise Pay::Stripe::Error, e
166
185
  end
167
186
 
168
- def on_grace_period?
169
- canceled? && Time.current < ends_at
170
- end
171
-
172
187
  def paused?
173
- pause_behavior.present?
188
+ pause_behavior == "void"
174
189
  end
175
190
 
176
191
  # Pauses a Stripe subscription
@@ -179,18 +194,32 @@ module Pay
179
194
  # pause(behavior: "keep_as_draft")
180
195
  # pause(behavior: "void")
181
196
  # pause(behavior: "mark_uncollectible", resumes_at: 1.month.from_now)
197
+ #
198
+ # `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.
199
+ # `keep_as_draft` - If you want to temporarily offer your services for free and collect payments later
200
+ # `mark_uncollectible` - If you want to offer your services for free
201
+ #
202
+ # 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`
203
+ # Other pause_behaviors do not set `pause_starts_at` because they are used for offering free services
182
204
  def pause(**options)
183
- attributes = {pause_collection: options.reverse_merge(behavior: "mark_uncollectible")}
205
+ attributes = {pause_collection: options.reverse_merge(behavior: "void")}
184
206
  @stripe_subscription = ::Stripe::Subscription.update(processor_id, attributes.merge(expand_options), stripe_options)
207
+ behavior = @stripe_subscription.pause_collection&.behavior
185
208
  pay_subscription.update(
186
- pause_behavior: @stripe_subscription.pause_collection&.behavior,
187
- pause_resumes_at: (@stripe_subscription.pause_collection&.resumes_at ? Time.at(@stripe_subscription.pause_collection&.resumes_at) : nil)
209
+ pause_behavior: behavior,
210
+ pause_resumes_at: (@stripe_subscription.pause_collection&.resumes_at ? Time.at(@stripe_subscription.pause_collection&.resumes_at) : nil),
211
+ pause_starts_at: ((behavior == "void") ? Time.at(@stripe_subscription.current_period_end) : nil)
188
212
  )
189
213
  end
190
214
 
215
+ # Unpauses a subscription
191
216
  def unpause
192
217
  @stripe_subscription = ::Stripe::Subscription.update(processor_id, {pause_collection: nil}.merge(expand_options), stripe_options)
193
- pay_subscription.update(pause_behavior: nil, pause_resumes_at: nil)
218
+ pay_subscription.update(
219
+ pause_behavior: nil,
220
+ pause_resumes_at: nil,
221
+ pause_starts_at: nil
222
+ )
194
223
  end
195
224
 
196
225
  def resume
@@ -215,20 +244,24 @@ module Pay
215
244
  raise Pay::Stripe::Error, e
216
245
  end
217
246
 
218
- def swap(plan)
247
+ def swap(plan, **options)
219
248
  raise ArgumentError, "plan must be a string" unless plan.is_a?(String)
220
249
 
250
+ proration_behavior = options.delete(:proration_behavior) || (prorate ? "always_invoice" : "none")
251
+
221
252
  @stripe_subscription = ::Stripe::Subscription.update(
222
253
  processor_id,
223
254
  {
224
255
  cancel_at_period_end: false,
225
256
  plan: plan,
226
- proration_behavior: (prorate ? "create_prorations" : "none"),
257
+ proration_behavior: proration_behavior,
227
258
  trial_end: (on_trial? ? trial_ends_at.to_i : "now"),
228
259
  quantity: quantity
229
260
  }.merge(expand_options),
230
261
  stripe_options
231
262
  )
263
+
264
+ pay_subscription.sync!(object: @stripe_subscription)
232
265
  rescue ::Stripe::StripeError => e
233
266
  raise Pay::Stripe::Error, e
234
267
  end
@@ -262,6 +295,11 @@ module Pay
262
295
  ::Stripe::Invoice.upcoming(options.merge(subscription: processor_id), stripe_options)
263
296
  end
264
297
 
298
+ # Retries the latest invoice for a Past Due subscription
299
+ def retry_failed_payment
300
+ subscription.latest_invoice.pay
301
+ end
302
+
265
303
  private
266
304
 
267
305
  # Options for Stripe requests
@@ -9,9 +9,7 @@ module Pay
9
9
 
10
10
  def call(event)
11
11
  object = event.data.object
12
- object.charges.data.each do |charge|
13
- Pay::Stripe::Charge.sync(charge.id, stripe_account: event.try(:account))
14
- end
12
+ Pay::Stripe::Charge.sync(object.latest_charge, stripe_account: event.try(:account))
15
13
  end
16
14
  end
17
15
  end
@@ -4,10 +4,7 @@ module Pay
4
4
  class PaymentMethodAttached
5
5
  def call(event)
6
6
  object = event.data.object
7
- pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.customer)
8
- return unless pay_customer
9
-
10
- pay_customer.save_payment_method(object, default: false)
7
+ Pay::Stripe::PaymentMethod.sync(object.id, stripe_account: event.try(:account))
11
8
  end
12
9
  end
13
10
  end
@@ -4,13 +4,7 @@ module Pay
4
4
  class PaymentMethodUpdated
5
5
  def call(event)
6
6
  object = event.data.object
7
-
8
7
  if object.customer
9
- pay_customer = Pay::Customer.find_by(processor: :stripe, processor_id: object.customer)
10
-
11
- # Couldn't find user, we can skip
12
- return unless pay_customer.present?
13
-
14
8
  Pay::Stripe::PaymentMethod.sync(object.id, stripe_account: event.try(:account))
15
9
  else
16
10
  # If customer was removed, we should delete the payment method if it exists
@@ -0,0 +1,24 @@
1
+ module Pay
2
+ module Stripe
3
+ module Webhooks
4
+ class SubscriptionTrialWillEnd
5
+ def call(event)
6
+ object = event.data.object
7
+
8
+ pay_subscription = Pay::Subscription.find_by_processor_and_id(:stripe, object.id)
9
+ return if pay_subscription.nil?
10
+
11
+ pay_subscription.sync!
12
+
13
+ pay_user_mailer = Pay.mailer.with(pay_customer: pay_subscription.customer, pay_subscription: pay_subscription)
14
+
15
+ if Pay.send_email?(:subscription_trial_will_end, pay_subscription) && pay_subscription.on_trial?
16
+ pay_user_mailer.subscription_trial_will_end.deliver_later
17
+ elsif Pay.send_email?(:subscription_trial_ended, pay_subscription) && pay_subscription.trial_ended?
18
+ pay_user_mailer.subscription_trial_ended.deliver_later
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/pay/stripe.rb CHANGED
@@ -24,6 +24,7 @@ module Pay
24
24
  autoload :SubscriptionDeleted, "pay/stripe/webhooks/subscription_deleted"
25
25
  autoload :SubscriptionRenewing, "pay/stripe/webhooks/subscription_renewing"
26
26
  autoload :SubscriptionUpdated, "pay/stripe/webhooks/subscription_updated"
27
+ autoload :SubscriptionTrialWillEnd, "pay/stripe/webhooks/subscription_trial_will_end"
27
28
  end
28
29
 
29
30
  extend Env
@@ -31,12 +32,12 @@ module Pay
31
32
  def self.enabled?
32
33
  return false unless Pay.enabled_processors.include?(:stripe) && defined?(::Stripe)
33
34
 
34
- Pay::Engine.version_matches?(required: "~> 7", current: ::Stripe::VERSION) || (raise "[Pay] stripe gem must be version ~> 7")
35
+ Pay::Engine.version_matches?(required: "~> 8", current: ::Stripe::VERSION) || (raise "[Pay] stripe gem must be version ~> 8")
35
36
  end
36
37
 
37
38
  def self.setup
38
39
  ::Stripe.api_key = private_key
39
- ::Stripe.api_version = "2022-08-01"
40
+ ::Stripe.api_version = "2022-11-15"
40
41
 
41
42
  # Used by Stripe to identify Pay for support
42
43
  ::Stripe.set_app_info("PayRails", partner_id: "pp_partner_IqhY0UExnJYLxg", version: Pay::VERSION, url: "https://github.com/pay-rails/pay")
@@ -86,6 +87,9 @@ module Pay
86
87
  # When a customers subscription is canceled, we want to update our records
87
88
  events.subscribe "stripe.customer.subscription.deleted", Pay::Stripe::Webhooks::SubscriptionDeleted.new
88
89
 
90
+ # When a customers subscription trial period is 3 days from ending or ended immediately this event is fired
91
+ events.subscribe "stripe.customer.subscription.trial_will_end", Pay::Stripe::Webhooks::SubscriptionTrialWillEnd.new
92
+
89
93
  # Monitor changes for customer's default card changing
90
94
  events.subscribe "stripe.customer.updated", Pay::Stripe::Webhooks::CustomerUpdated.new
91
95
 
data/lib/pay/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pay
2
- VERSION = "5.0.3"
2
+ VERSION = "6.0.0"
3
3
  end
@@ -27,7 +27,7 @@ module Pay
27
27
 
28
28
  # Unsubscribe
29
29
  def unsubscribe(name)
30
- backend.unsubscribe name
30
+ backend.unsubscribe name_with_namespace(name)
31
31
  end
32
32
 
33
33
  # Called to process an event
data/lib/pay.rb CHANGED
@@ -61,6 +61,8 @@ module Pay
61
61
  @@emails.subscription_renewing = ->(pay_subscription, price) {
62
62
  (price&.type == "recurring") && (price.recurring&.interval == "year")
63
63
  }
64
+ @@emails.subscription_trial_will_end = true
65
+ @@emails.subscription_trial_ended = true
64
66
 
65
67
  @@mailer = "Pay::UserMailer"
66
68
 
@@ -76,6 +78,27 @@ module Pay
76
78
  mattr_accessor :parent_mailer
77
79
  @@parent_mailer = "Pay::ApplicationMailer"
78
80
 
81
+ # Should return a hash of arguments for the `mail` call in UserMailer
82
+ mattr_accessor :mail_arguments
83
+ @@mail_arguments = ->(mailer, params) {
84
+ {
85
+ to: Pay.mail_to.call(mailer, params)
86
+ }
87
+ }
88
+
89
+ # Should return String or Array of email recipients
90
+ mattr_accessor :mail_to
91
+ @@mail_to = ->(mailer, params) {
92
+ if ActionMailer::Base.respond_to?(:email_address_with_name)
93
+ ActionMailer::Base.email_address_with_name(params[:pay_customer].email, params[:pay_customer].customer_name)
94
+ else
95
+ Mail::Address.new.tap do |builder|
96
+ builder.address = params[:pay_customer].email
97
+ builder.display_name = params[:pay_customer].customer_name.presence
98
+ end.to_s
99
+ end
100
+ }
101
+
79
102
  def self.setup
80
103
  yield self
81
104
  end
data/lib/tasks/pay.rake CHANGED
@@ -26,6 +26,6 @@ def sync_default_payment_method(pay_customer, retries: 2)
26
26
  rescue
27
27
  sleep 0.5
28
28
  try += 1
29
- try <= retries ? retry : raise
29
+ (try <= retries) ? retry : raise
30
30
  end
31
31
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pay
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.3
4
+ version: 6.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Charnes
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-08-19 00:00:00.000000000 Z
12
+ date: 2022-11-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -119,6 +119,8 @@ files:
119
119
  - app/views/pay/user_mailer/receipt.html.erb
120
120
  - app/views/pay/user_mailer/refund.html.erb
121
121
  - app/views/pay/user_mailer/subscription_renewing.html.erb
122
+ - app/views/pay/user_mailer/subscription_trial_ended.html.erb
123
+ - app/views/pay/user_mailer/subscription_trial_will_end.html.erb
122
124
  - config/currencies/iso.json
123
125
  - config/locales/en.yml
124
126
  - config/routes.rb
@@ -151,6 +153,7 @@ files:
151
153
  - lib/pay/fake_processor/billable.rb
152
154
  - lib/pay/fake_processor/charge.rb
153
155
  - lib/pay/fake_processor/error.rb
156
+ - lib/pay/fake_processor/merchant.rb
154
157
  - lib/pay/fake_processor/payment_method.rb
155
158
  - lib/pay/fake_processor/subscription.rb
156
159
  - lib/pay/nano_id.rb
@@ -191,6 +194,7 @@ files:
191
194
  - lib/pay/stripe/webhooks/subscription_created.rb
192
195
  - lib/pay/stripe/webhooks/subscription_deleted.rb
193
196
  - lib/pay/stripe/webhooks/subscription_renewing.rb
197
+ - lib/pay/stripe/webhooks/subscription_trial_will_end.rb
194
198
  - lib/pay/stripe/webhooks/subscription_updated.rb
195
199
  - lib/pay/version.rb
196
200
  - lib/pay/webhooks.rb