pay 0.0.2 → 1.0.0.beta2
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 +5 -5
- data/MIT-LICENSE +1 -1
- data/README.md +256 -29
- data/Rakefile +1 -6
- data/app/controllers/pay/webhooks/braintree_controller.rb +56 -0
- data/app/jobs/pay/email_sync_job.rb +12 -0
- data/app/mailers/pay/user_mailer.rb +42 -0
- data/app/models/pay/charge.rb +31 -0
- data/app/models/pay/subscription.rb +77 -0
- data/app/views/pay/user_mailer/receipt.html.erb +20 -0
- data/app/views/pay/user_mailer/refund.html.erb +21 -0
- data/app/views/pay/user_mailer/subscription_renewing.html.erb +6 -0
- data/config/routes.rb +3 -1
- data/db/migrate/20170205020145_create_subscriptions.rb +1 -1
- data/db/migrate/20170503131610_add_fields_to_users.rb +3 -2
- data/db/migrate/20170727235816_create_charges.rb +17 -0
- data/lib/generators/pay/email_views_generator.rb +13 -0
- data/lib/pay.rb +65 -1
- data/lib/pay/billable.rb +54 -24
- data/lib/pay/billable/sync_email.rb +41 -0
- data/lib/pay/braintree.rb +16 -0
- data/lib/pay/braintree/api.rb +30 -0
- data/lib/pay/braintree/billable.rb +219 -0
- data/lib/pay/braintree/charge.rb +27 -0
- data/lib/pay/braintree/subscription.rb +173 -0
- data/lib/pay/engine.rb +14 -1
- data/lib/pay/receipts.rb +37 -0
- data/lib/pay/stripe.rb +17 -0
- data/lib/pay/stripe/api.rb +13 -0
- data/lib/pay/stripe/billable.rb +143 -0
- data/lib/pay/stripe/charge.rb +30 -0
- data/lib/pay/stripe/subscription.rb +48 -0
- data/lib/pay/stripe/webhooks.rb +39 -0
- data/lib/pay/stripe/webhooks/charge_refunded.rb +25 -0
- data/lib/pay/stripe/webhooks/charge_succeeded.rb +47 -0
- data/lib/pay/stripe/webhooks/customer_deleted.rb +31 -0
- data/lib/pay/stripe/webhooks/customer_updated.rb +19 -0
- data/lib/pay/stripe/webhooks/source_deleted.rb +19 -0
- data/lib/pay/stripe/webhooks/subscription_created.rb +46 -0
- data/lib/pay/stripe/webhooks/subscription_deleted.rb +21 -0
- data/lib/pay/stripe/webhooks/subscription_renewing.rb +25 -0
- data/lib/pay/stripe/webhooks/subscription_updated.rb +35 -0
- data/lib/pay/version.rb +1 -1
- metadata +124 -30
- data/app/models/subscription.rb +0 -59
- data/config/initializers/pay.rb +0 -3
- data/config/initializers/stripe.rb +0 -1
- data/db/development.sqlite3 +0 -0
- data/lib/pay/billable/braintree.rb +0 -57
- data/lib/pay/billable/stripe.rb +0 -47
- data/lib/tasks/pay_tasks.rake +0 -4
@@ -0,0 +1,41 @@
|
|
1
|
+
module Pay
|
2
|
+
module Billable
|
3
|
+
module SyncEmail
|
4
|
+
# Sync email address changes from the model to the processor.
|
5
|
+
# This way they're kept in sync and email notifications are
|
6
|
+
# always sent to the correct email address after an update.
|
7
|
+
#
|
8
|
+
# Processor classes simply need to implement a method named:
|
9
|
+
#
|
10
|
+
# update_PROCESSOR_email!
|
11
|
+
#
|
12
|
+
# This method should take the email address on the billable
|
13
|
+
# object and update the associated API record.
|
14
|
+
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
|
17
|
+
included do
|
18
|
+
after_update :enqeue_sync_email_job,
|
19
|
+
if: :should_sync_email_with_processor?
|
20
|
+
end
|
21
|
+
|
22
|
+
def should_sync_email_with_processor?
|
23
|
+
respond_to? :saved_change_to_email?
|
24
|
+
end
|
25
|
+
|
26
|
+
def sync_email_with_processor
|
27
|
+
send("update_#{processor}_email!")
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def enqeue_sync_email_job
|
33
|
+
# Only update if the processor id is the same
|
34
|
+
# This prevents duplicate API hits if this is their first time
|
35
|
+
if processor_id? && !processor_id_changed? && saved_change_to_email?
|
36
|
+
EmailSyncJob.perform_later(id)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'pay/braintree/api'
|
2
|
+
require 'pay/braintree/billable'
|
3
|
+
require 'pay/braintree/charge'
|
4
|
+
require 'pay/braintree/subscription'
|
5
|
+
|
6
|
+
module Pay
|
7
|
+
module Braintree
|
8
|
+
def self.setup
|
9
|
+
Pay::Braintree::Api.set_api_keys
|
10
|
+
|
11
|
+
Pay.charge_model.include Pay::Braintree::Charge
|
12
|
+
Pay.subscription_model.include Pay::Braintree::Subscription
|
13
|
+
Pay.user_model.include Pay::Braintree::Billable
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Pay
|
2
|
+
module Braintree
|
3
|
+
module Api
|
4
|
+
def self.set_api_keys
|
5
|
+
environment = get_key_for(:environment, 'sandbox')
|
6
|
+
merchant_id = get_key_for(:merchant_id)
|
7
|
+
public_key = get_key_for(:public_key)
|
8
|
+
private_key = get_key_for(:private_key)
|
9
|
+
|
10
|
+
Pay.braintree_gateway = ::Braintree::Gateway.new(
|
11
|
+
environment: environment.to_sym,
|
12
|
+
merchant_id: merchant_id,
|
13
|
+
public_key: public_key,
|
14
|
+
private_key: private_key
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.get_key_for(name, default = '')
|
19
|
+
env = Rails.env.to_sym
|
20
|
+
secrets = Rails.application.secrets
|
21
|
+
credentials = Rails.application.credentials
|
22
|
+
|
23
|
+
ENV["BRAINTREE_#{name.upcase}"] ||
|
24
|
+
secrets.dig(env, :braintree, name) ||
|
25
|
+
credentials.dig(env, :braintree, name) ||
|
26
|
+
default
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
module Pay
|
2
|
+
module Braintree
|
3
|
+
module Billable
|
4
|
+
# Handles Billable#customer
|
5
|
+
#
|
6
|
+
# Returns Braintree::Customer
|
7
|
+
def braintree_customer
|
8
|
+
if processor_id?
|
9
|
+
gateway.customer.find(processor_id)
|
10
|
+
else
|
11
|
+
result = gateway.customer.create(
|
12
|
+
email: email,
|
13
|
+
first_name: try(:first_name),
|
14
|
+
last_name: try(:last_name),
|
15
|
+
payment_method_nonce: card_token,
|
16
|
+
)
|
17
|
+
raise Pay::Error.new(result.message) unless result.success?
|
18
|
+
|
19
|
+
update(processor: 'braintree', processor_id: result.customer.id)
|
20
|
+
|
21
|
+
if card_token.present?
|
22
|
+
update_braintree_card_on_file result.customer.payment_methods.last
|
23
|
+
end
|
24
|
+
|
25
|
+
result.customer
|
26
|
+
end
|
27
|
+
rescue ::Braintree::BraintreeError => e
|
28
|
+
raise Error, e.message
|
29
|
+
end
|
30
|
+
|
31
|
+
# Handles Billable#charge
|
32
|
+
#
|
33
|
+
# Returns a Pay::Charge
|
34
|
+
def create_braintree_charge(amount, options={})
|
35
|
+
args = {
|
36
|
+
amount: amount / 100.0,
|
37
|
+
customer_id: customer.id,
|
38
|
+
options: { submit_for_settlement: true }
|
39
|
+
}.merge(options)
|
40
|
+
|
41
|
+
result = gateway.transaction.sale(args)
|
42
|
+
save_braintree_transaction(result.transaction) if result.success?
|
43
|
+
rescue ::BraintreeError => e
|
44
|
+
raise Error, e.message
|
45
|
+
end
|
46
|
+
|
47
|
+
# Handles Billable#subscribe
|
48
|
+
#
|
49
|
+
# Returns Pay::Subscription
|
50
|
+
def create_braintree_subscription(name, plan, options={})
|
51
|
+
token = customer.payment_methods.find(&:default?).try(:token)
|
52
|
+
raise Pay::Error, "Customer has no default payment method" if token.nil?
|
53
|
+
|
54
|
+
subscription_options = options.merge(
|
55
|
+
payment_method_token: token,
|
56
|
+
plan_id: plan
|
57
|
+
)
|
58
|
+
|
59
|
+
result = gateway.subscription.create(subscription_options)
|
60
|
+
raise Pay::Error.new(result.message) unless result.success?
|
61
|
+
|
62
|
+
create_subscription(result.subscription, 'braintree', name, plan)
|
63
|
+
rescue ::Braintree::BraintreeError => e
|
64
|
+
raise Error, e.message
|
65
|
+
end
|
66
|
+
|
67
|
+
# Handles Billable#update_card
|
68
|
+
#
|
69
|
+
# Returns true if successful
|
70
|
+
def update_braintree_card(token)
|
71
|
+
result = gateway.payment_method.create(
|
72
|
+
customer_id: processor_id,
|
73
|
+
payment_method_nonce: token,
|
74
|
+
options: {
|
75
|
+
make_default: true,
|
76
|
+
verify_card: true
|
77
|
+
}
|
78
|
+
)
|
79
|
+
raise Pay::Error.new(result.message) unless result.success?
|
80
|
+
|
81
|
+
update_braintree_card_on_file result.payment_method
|
82
|
+
update_subscriptions_to_payment_method(result.payment_method.token)
|
83
|
+
true
|
84
|
+
rescue ::Braintree::BraintreeError => e
|
85
|
+
raise Error, e.message
|
86
|
+
end
|
87
|
+
|
88
|
+
def update_braintree_email!
|
89
|
+
braintree_customer.update(
|
90
|
+
email: email,
|
91
|
+
first_name: try(:first_name),
|
92
|
+
last_name: try(:last_name),
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
def braintree_trial_end_date(subscription)
|
97
|
+
return unless subscription.trial_period
|
98
|
+
Time.zone.parse(subscription.first_billing_date)
|
99
|
+
end
|
100
|
+
|
101
|
+
def update_subscriptions_to_payment_method(token)
|
102
|
+
subscriptions.each do |subscription|
|
103
|
+
if subscription.active?
|
104
|
+
gateway.subscription.update(subscription.processor_id, { payment_method_token: token })
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def braintree_subscription(subscription_id)
|
110
|
+
gateway.subscription.find(subscription_id)
|
111
|
+
end
|
112
|
+
|
113
|
+
def braintree_invoice!
|
114
|
+
# pass
|
115
|
+
end
|
116
|
+
|
117
|
+
def braintree_upcoming_invoice
|
118
|
+
# pass
|
119
|
+
end
|
120
|
+
|
121
|
+
def braintree?
|
122
|
+
processor == "braintree"
|
123
|
+
end
|
124
|
+
|
125
|
+
def paypal?
|
126
|
+
braintree? && card_type == "PayPal"
|
127
|
+
end
|
128
|
+
|
129
|
+
def save_braintree_transaction(transaction)
|
130
|
+
attrs = card_details_for_braintree_transaction(transaction)
|
131
|
+
attrs.merge!(amount: transaction.amount.to_f * 100)
|
132
|
+
|
133
|
+
charge = charges.find_or_initialize_by(
|
134
|
+
processor: :braintree,
|
135
|
+
processor_id: transaction.id
|
136
|
+
)
|
137
|
+
charge.update(attrs)
|
138
|
+
charge
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def gateway
|
144
|
+
Pay.braintree_gateway
|
145
|
+
end
|
146
|
+
|
147
|
+
def update_braintree_card_on_file(payment_method)
|
148
|
+
case payment_method
|
149
|
+
when ::Braintree::CreditCard
|
150
|
+
update!(
|
151
|
+
card_type: payment_method.card_type,
|
152
|
+
card_last4: payment_method.last_4,
|
153
|
+
card_exp_month: payment_method.expiration_month,
|
154
|
+
card_exp_year: payment_method.expiration_year
|
155
|
+
)
|
156
|
+
|
157
|
+
when ::Braintree::PayPalAccount
|
158
|
+
update!(
|
159
|
+
card_type: "PayPal",
|
160
|
+
card_last4: payment_method.email
|
161
|
+
)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Clear the card token so we don't accidentally update twice
|
165
|
+
self.card_token = nil
|
166
|
+
end
|
167
|
+
|
168
|
+
def card_details_for_braintree_transaction(transaction)
|
169
|
+
case transaction.payment_instrument_type
|
170
|
+
when "credit_card", "samsung_pay_card", "masterpass_card", "samsung_pay_card", "visa_checkout_card"
|
171
|
+
payment_method = transaction.send("#{transaction.payment_instrument_type}_details")
|
172
|
+
{
|
173
|
+
card_type: payment_method.card_type,
|
174
|
+
card_last4: payment_method.last_4,
|
175
|
+
card_exp_month: payment_method.expiration_month,
|
176
|
+
card_exp_year: payment_method.expiration_year,
|
177
|
+
}
|
178
|
+
|
179
|
+
when "paypal_account"
|
180
|
+
{
|
181
|
+
card_type: "PayPal",
|
182
|
+
card_last4: transaction.paypal_details.payer_email,
|
183
|
+
card_exp_month: nil,
|
184
|
+
card_exp_year: nil,
|
185
|
+
}
|
186
|
+
|
187
|
+
when "android_pay_card"
|
188
|
+
payment_method = transaction.android_pay_details
|
189
|
+
{
|
190
|
+
card_type: payment_method.source_card_type,
|
191
|
+
card_last4: payment_method.source_card_last_4,
|
192
|
+
card_exp_month: payment_method.expiration_month,
|
193
|
+
card_exp_year: payment_method.expiration_year,
|
194
|
+
}
|
195
|
+
|
196
|
+
when "venmo_account"
|
197
|
+
{
|
198
|
+
card_type: "Venmo",
|
199
|
+
card_last4: transaction.venmo_account_details.username,
|
200
|
+
card_exp_month: nil,
|
201
|
+
card_exp_year: nil,
|
202
|
+
}
|
203
|
+
|
204
|
+
when "apple_pay_card"
|
205
|
+
payment_method = transaction.apple_pay_details
|
206
|
+
{
|
207
|
+
card_type: payment_method.card_type,
|
208
|
+
card_last4: payment_method.last_4,
|
209
|
+
card_exp_month: payment_method.expiration_month,
|
210
|
+
card_exp_year: payment_method.expiration_year,
|
211
|
+
}
|
212
|
+
|
213
|
+
else
|
214
|
+
{}
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Pay
|
2
|
+
module Braintree
|
3
|
+
|
4
|
+
module Charge
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
def braintree?
|
8
|
+
processor == "braintree"
|
9
|
+
end
|
10
|
+
|
11
|
+
def braintree_charge
|
12
|
+
Pay.braintree_gateway.transaction.find(processor_id)
|
13
|
+
rescue ::Braintree::BraintreeError => e
|
14
|
+
raise Error, e.message
|
15
|
+
end
|
16
|
+
|
17
|
+
def braintree_refund!(amount_to_refund)
|
18
|
+
Pay.braintree_gateway.transaction.refund(processor_id, amount_to_refund / 100.0)
|
19
|
+
|
20
|
+
update(amount_refunded: amount_to_refund)
|
21
|
+
rescue ::Braintree::BraintreeError => e
|
22
|
+
raise Error, e.message
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
module Pay
|
2
|
+
module Braintree
|
3
|
+
module Subscription
|
4
|
+
def braintree?
|
5
|
+
processor == "braintree"
|
6
|
+
end
|
7
|
+
|
8
|
+
def braintree_cancel
|
9
|
+
subscription = processor_subscription
|
10
|
+
|
11
|
+
if on_trial?
|
12
|
+
gateway.subscription.cancel(processor_subscription.id)
|
13
|
+
update(ends_at: trial_ends_at)
|
14
|
+
else
|
15
|
+
gateway.subscription.update(subscription.id, {
|
16
|
+
number_of_billing_cycles: subscription.current_billing_cycle
|
17
|
+
})
|
18
|
+
update(ends_at: subscription.billing_period_end_date)
|
19
|
+
end
|
20
|
+
rescue ::Braintree::BraintreeError => e
|
21
|
+
raise Error, e.message
|
22
|
+
end
|
23
|
+
|
24
|
+
def braintree_cancel_now!
|
25
|
+
gateway.subscription.cancel(processor_subscription.id)
|
26
|
+
update(ends_at: Time.zone.now)
|
27
|
+
rescue ::Braintree::BraintreeError => e
|
28
|
+
raise Error, e.message
|
29
|
+
end
|
30
|
+
|
31
|
+
def braintree_resume
|
32
|
+
if cancelled? && on_trial?
|
33
|
+
duration = trial_ends_at.to_date - Date.today
|
34
|
+
|
35
|
+
owner.subscribe(
|
36
|
+
name: name,
|
37
|
+
plan: processor_plan,
|
38
|
+
trial_period: true,
|
39
|
+
trial_duration: duration.to_i,
|
40
|
+
trial_duration_unit: :day
|
41
|
+
)
|
42
|
+
|
43
|
+
else
|
44
|
+
subscription = processor_subscription
|
45
|
+
|
46
|
+
gateway.subscription.update(subscription.id, {
|
47
|
+
never_expires: true,
|
48
|
+
number_of_billing_cycles: nil
|
49
|
+
})
|
50
|
+
end
|
51
|
+
rescue ::Braintree::BraintreeError => e
|
52
|
+
raise Error, e.message
|
53
|
+
end
|
54
|
+
|
55
|
+
def braintree_swap(plan)
|
56
|
+
if on_grace_period? && processor_plan == plan
|
57
|
+
resume
|
58
|
+
return
|
59
|
+
end
|
60
|
+
|
61
|
+
if !active?
|
62
|
+
owner.subscribe(name, plan, processor, trial_period: false)
|
63
|
+
return
|
64
|
+
end
|
65
|
+
|
66
|
+
braintree_plan = find_braintree_plan(plan)
|
67
|
+
|
68
|
+
if would_change_billing_frequency?(braintree_plan) && prorate?
|
69
|
+
swap_across_frequencies(braintree_plan)
|
70
|
+
return
|
71
|
+
end
|
72
|
+
|
73
|
+
subscription = processor_subscription
|
74
|
+
|
75
|
+
result = gateway.subscription.update(subscription.id, {
|
76
|
+
plan_id: braintree_plan.id,
|
77
|
+
price: braintree_plan.price,
|
78
|
+
never_expires: true,
|
79
|
+
number_of_billing_cycles: nil,
|
80
|
+
options: {
|
81
|
+
prorate_charges: prorate?,
|
82
|
+
}
|
83
|
+
})
|
84
|
+
|
85
|
+
if result.success?
|
86
|
+
update(processor_plan: braintree_plan.id, ends_at: nil)
|
87
|
+
else
|
88
|
+
raise Error, "Braintree failed to swap plans: #{result.message}"
|
89
|
+
end
|
90
|
+
rescue ::Braintree::BraintreeError => e
|
91
|
+
raise Error, e.message
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def gateway
|
97
|
+
Pay.braintree_gateway
|
98
|
+
end
|
99
|
+
|
100
|
+
def would_change_billing_frequency?(plan)
|
101
|
+
plan.billing_frequency != find_braintree_plan(processor_plan).billing_frequency
|
102
|
+
end
|
103
|
+
|
104
|
+
def find_braintree_plan(id)
|
105
|
+
@braintree_plans ||= gateway.plan.all
|
106
|
+
@braintree_plans.find{ |p| p.id == id }
|
107
|
+
end
|
108
|
+
|
109
|
+
# Helper methods for swapping plans
|
110
|
+
def switching_to_monthly_plan?(current_plan, plan)
|
111
|
+
current_plan.billing_frequency == 12 && plan.billing_frequency == 1
|
112
|
+
end
|
113
|
+
|
114
|
+
def discount_for_switching_to_monthly(current_plan, plan)
|
115
|
+
cycles = (money_remaining_on_yearly_plan(current_plan) / plan.price).floor
|
116
|
+
OpenStruct.new(
|
117
|
+
amount: plan.price,
|
118
|
+
number_of_billing_cycles: cycles
|
119
|
+
)
|
120
|
+
end
|
121
|
+
|
122
|
+
def money_remaining_on_yearly_plan(current_plan)
|
123
|
+
end_date = processor_subscription.billing_period_end_date.to_date
|
124
|
+
(current_plan.price / 365) * (end_date - Date.today)
|
125
|
+
end
|
126
|
+
|
127
|
+
def discount_for_switching_to_yearly
|
128
|
+
amount = 0
|
129
|
+
|
130
|
+
processor_subscription.discounts.each do |discount|
|
131
|
+
if discount.id == 'plan-credit'
|
132
|
+
amount += discount.amount * discount.number_of_billing_cycles
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
OpenStruct.new(
|
137
|
+
amount: amount,
|
138
|
+
number_of_billing_cycles: 1
|
139
|
+
)
|
140
|
+
end
|
141
|
+
|
142
|
+
def swap_across_frequencies(plan)
|
143
|
+
current_plan = find_braintree_plan(processor_plan)
|
144
|
+
|
145
|
+
discount = if switching_to_monthly_plan?(current_plan, plan)
|
146
|
+
discount_for_switching_to_monthly(current_plan, plan)
|
147
|
+
else
|
148
|
+
discount_for_switching_to_yearly
|
149
|
+
end
|
150
|
+
|
151
|
+
options = {}
|
152
|
+
|
153
|
+
if discount.amount > 0 && discount.number_of_billing_cycles > 0
|
154
|
+
options = {
|
155
|
+
discounts: {
|
156
|
+
add: [
|
157
|
+
{
|
158
|
+
inherited_from_id: 'plan-credit',
|
159
|
+
amount: discount.amount,
|
160
|
+
number_of_billing_cycles: discount.number_of_billing_cycles
|
161
|
+
}
|
162
|
+
]
|
163
|
+
}
|
164
|
+
}
|
165
|
+
end
|
166
|
+
|
167
|
+
cancel_now!
|
168
|
+
|
169
|
+
owner.subscribe(options.merge(name: name, plan: plan.id))
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|