solidus_stripe 4.4.1 → 5.0.0.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.circleci/config.yml +92 -52
- data/.gitignore +3 -0
- data/.rubocop.yml +94 -4
- data/.yardopts +1 -0
- data/Gemfile +10 -30
- data/LICENSE +2 -2
- data/Procfile.dev +3 -0
- data/README.md +145 -215
- data/Rakefile +5 -44
- data/app/assets/javascripts/spree/backend/solidus_stripe.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_stripe.css +4 -0
- data/app/controllers/solidus_stripe/intents_controller.rb +36 -52
- data/app/controllers/solidus_stripe/webhooks_controller.rb +28 -0
- data/app/models/concerns/solidus_stripe/log_entries.rb +31 -0
- data/app/models/solidus_stripe/customer.rb +21 -0
- data/app/models/solidus_stripe/gateway.rb +231 -0
- data/app/models/solidus_stripe/payment_intent.rb +111 -0
- data/app/models/solidus_stripe/payment_method.rb +106 -0
- data/app/models/solidus_stripe/payment_source.rb +31 -0
- data/app/models/solidus_stripe/slug_entry.rb +20 -0
- data/app/models/solidus_stripe.rb +7 -0
- data/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb +28 -0
- data/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb +112 -0
- data/app/views/spree/admin/payments/source_forms/_stripe.html.erb +29 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/_stripe.html.erb +14 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_card.html.erb +8 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_default.html.erb +7 -0
- data/app/views/spree/admin/payments/source_views/_stripe.html.erb +15 -0
- data/app/views/spree/api/payments/source_views/_stripe.json.jbuilder +8 -0
- data/bin/dev +13 -0
- data/bin/dummy-app +29 -0
- data/bin/rails +38 -3
- data/bin/rails-dummy-app +3 -0
- data/bin/rails-engine +1 -11
- data/bin/rails-new +55 -0
- data/bin/rails-sandbox +1 -14
- data/bin/rspec +10 -0
- data/bin/sandbox +12 -74
- data/bin/setup +1 -0
- data/bin/update-migrations +56 -0
- data/codecov.yml +12 -0
- data/config/locales/en.yml +16 -1
- data/config/routes.rb +5 -11
- data/coverage.rb +42 -0
- data/db/migrate/20230109183332_create_solidus_stripe_payment_sources.rb +10 -0
- data/db/migrate/20230303154931_create_solidus_stripe_setup_intent.rb +10 -0
- data/db/migrate/20230306105520_create_solidus_stripe_payment_intents.rb +10 -0
- data/db/migrate/20230308122414_create_solidus_stripe_webhook_endpoints.rb +10 -0
- data/db/migrate/20230310152615_add_payment_method_reference_to_stripe_intents.rb +6 -0
- data/db/migrate/20230310171444_normalize_stripe_intent_id_attributes.rb +6 -0
- data/db/migrate/20230313150008_create_solidus_stripe_customers.rb +16 -0
- data/db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb +13 -0
- data/db/migrate/20230403094916_rename_webhook_endpoint_to_payment_method_slug_entries.rb +5 -0
- data/db/seeds.rb +6 -24
- data/lib/generators/solidus_stripe/install/install_generator.rb +121 -14
- data/lib/generators/solidus_stripe/install/templates/app/assets/stylesheets/spree/frontend/solidus_stripe.css +13 -0
- data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js +39 -0
- data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_payment_controller.js +89 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/_stripe.html.erb +16 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_card.html.erb +8 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_default.html.erb +7 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +39 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb +20 -0
- data/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb +31 -0
- data/lib/solidus_stripe/configuration.rb +24 -3
- data/lib/solidus_stripe/engine.rb +19 -6
- data/lib/solidus_stripe/money_to_stripe_amount_converter.rb +109 -0
- data/lib/solidus_stripe/refunds_synchronizer.rb +96 -0
- data/lib/solidus_stripe/seeds.rb +19 -0
- data/lib/solidus_stripe/testing_support/factories.rb +153 -0
- data/lib/solidus_stripe/version.rb +1 -1
- data/lib/solidus_stripe/webhook/event.rb +90 -0
- data/lib/solidus_stripe.rb +0 -2
- data/solidus_stripe.gemspec +29 -6
- data/spec/lib/solidus_stripe/configuration_spec.rb +21 -0
- data/spec/lib/solidus_stripe/money_to_stripe_amount_converter_spec.rb +133 -0
- data/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb +238 -0
- data/spec/lib/solidus_stripe/seeds_spec.rb +43 -0
- data/spec/lib/solidus_stripe/webhook/event_spec.rb +134 -0
- data/spec/models/concerns/solidus_stripe/log_entries_spec.rb +54 -0
- data/spec/models/solidus_stripe/customer_spec.rb +47 -0
- data/spec/models/solidus_stripe/gateway_spec.rb +283 -0
- data/spec/models/solidus_stripe/payment_intent_spec.rb +17 -0
- data/spec/models/solidus_stripe/payment_method_spec.rb +137 -0
- data/spec/models/solidus_stripe/payment_source_spec.rb +25 -0
- data/spec/requests/solidus_stripe/intents_controller_spec.rb +29 -0
- data/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb +31 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/canceled_spec.rb +23 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/payment_failed_spec.rb +23 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/succeeded_spec.rb +29 -0
- data/spec/requests/solidus_stripe/webhooks_controller_spec.rb +52 -0
- data/spec/solidus_stripe_spec_helper.rb +10 -0
- data/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb +33 -0
- data/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb +297 -0
- data/spec/support/solidus_stripe/backend_test_helper.rb +210 -0
- data/spec/support/solidus_stripe/checkout_test_helper.rb +339 -0
- data/spec/support/solidus_stripe/factories.rb +5 -0
- data/spec/support/solidus_stripe/webhook/data_fixtures.rb +106 -0
- data/spec/support/solidus_stripe/webhook/event_with_context_factory.rb +82 -0
- data/spec/support/solidus_stripe/webhook/request_helper.rb +32 -0
- data/spec/system/backend/solidus_stripe/orders/payments_spec.rb +119 -0
- data/spec/system/frontend/.keep +0 -0
- data/spec/system/frontend/solidus_stripe/checkout_spec.rb +187 -0
- data/tmp/.keep +0 -0
- metadata +202 -78
- data/.rubocop_todo.yml +0 -298
- data/.travis.yml +0 -28
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-cart-page-checkout.js +0 -122
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-elements.js +0 -148
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-init.js +0 -20
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-intents.js +0 -84
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-request-button-shared.js +0 -160
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment.js +0 -16
- data/app/assets/javascripts/spree/frontend/solidus_stripe.js +0 -6
- data/app/controllers/solidus_stripe/payment_request_controller.rb +0 -52
- data/app/controllers/spree/stripe_controller.rb +0 -13
- data/app/decorators/models/spree/order_update_attributes_decorator.rb +0 -39
- data/app/decorators/models/spree/payment_decorator.rb +0 -11
- data/app/decorators/models/spree/refund_decorator.rb +0 -9
- data/app/models/solidus_stripe/address_from_params_service.rb +0 -72
- data/app/models/solidus_stripe/create_intents_payment_service.rb +0 -114
- data/app/models/solidus_stripe/prepare_order_for_payment_service.rb +0 -46
- data/app/models/solidus_stripe/shipping_rates_service.rb +0 -46
- data/app/models/spree/payment_method/stripe_credit_card.rb +0 -230
- data/bin/r +0 -13
- data/bin/sandbox_rails +0 -18
- data/db/migrate/20181010123508_update_stripe_payment_method_type_to_credit_card.rb +0 -21
- data/lib/assets/stylesheets/spree/frontend/solidus_stripe.scss +0 -11
- data/lib/solidus_stripe/testing_support/card_input_helper.rb +0 -34
- data/lib/tasks/solidus_stripe/db/seed.rake +0 -14
- data/lib/views/api/spree/api/payments/source_views/_stripe.json.jbuilder +0 -3
- data/lib/views/backend/spree/admin/log_entries/_stripe.html.erb +0 -28
- data/lib/views/backend/spree/admin/payments/source_forms/_stripe.html.erb +0 -1
- data/lib/views/backend/spree/admin/payments/source_views/_stripe.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/existing_payment/_stripe.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/_stripe.html.erb +0 -8
- data/lib/views/frontend/spree/checkout/payment/v2/_javascript.html.erb +0 -78
- data/lib/views/frontend/spree/checkout/payment/v3/_elements.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/v3/_form_elements.html.erb +0 -40
- data/lib/views/frontend/spree/checkout/payment/v3/_intents.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/v3/_stripe.html.erb +0 -2
- data/lib/views/frontend/spree/orders/_stripe_payment_request_button.html.erb +0 -14
- data/spec/features/stripe_checkout_spec.rb +0 -486
- data/spec/models/solidus_stripe/address_from_params_service_spec.rb +0 -87
- data/spec/models/solidus_stripe/create_intents_payment_service_spec.rb +0 -127
- data/spec/models/solidus_stripe/prepare_order_for_payment_service_spec.rb +0 -65
- data/spec/models/solidus_stripe/shipping_rates_service_spec.rb +0 -54
- data/spec/models/spree/payment_method/stripe_credit_card_spec.rb +0 -354
- data/spec/requests/payment_requests_spec.rb +0 -152
- data/spec/solidus_frontend_app_template.rb +0 -17
- data/spec/spec_helper.rb +0 -37
- data/spec/support/solidus_address_helper.rb +0 -15
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusStripe
|
|
4
|
+
class Customer < ApplicationRecord
|
|
5
|
+
belongs_to :payment_method
|
|
6
|
+
|
|
7
|
+
# Source is supposed to be a user or an order and needs to respond to #email
|
|
8
|
+
belongs_to :source, polymorphic: true
|
|
9
|
+
|
|
10
|
+
def self.retrieve_or_create_stripe_customer_id(payment_method:, order:)
|
|
11
|
+
instance = find_or_initialize_by(payment_method: payment_method, source: order.user || order)
|
|
12
|
+
|
|
13
|
+
instance.stripe_id ||
|
|
14
|
+
instance.create_stripe_customer.tap { instance.update!(stripe_id: _1.id) }.id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_stripe_customer
|
|
18
|
+
payment_method.gateway.request { Stripe::Customer.create(email: source.email) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stripe'
|
|
4
|
+
require "solidus_stripe/money_to_stripe_amount_converter"
|
|
5
|
+
require "solidus_stripe/refunds_synchronizer"
|
|
6
|
+
|
|
7
|
+
module SolidusStripe
|
|
8
|
+
# @see https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=checkout#auth-and-capture
|
|
9
|
+
# @see https://stripe.com/docs/charges/placing-a-hold
|
|
10
|
+
# @see https://guides.solidus.io/advanced-solidus/payments-and-refunds/#custom-payment-gateways
|
|
11
|
+
#
|
|
12
|
+
# ## About fractional amounts
|
|
13
|
+
#
|
|
14
|
+
# All methods in the Gateway class will have `amount_in_cents` arguments representing the
|
|
15
|
+
# fractional amount as defined by Spree::Money and will be translated to the fractional expected
|
|
16
|
+
# by Stripe, although for most currencies it's cents some will have a different multiplier and
|
|
17
|
+
# that is already took into account.
|
|
18
|
+
#
|
|
19
|
+
# @see SolidusStripe::MoneyToStripeAmountConverter
|
|
20
|
+
class Gateway
|
|
21
|
+
include SolidusStripe::MoneyToStripeAmountConverter
|
|
22
|
+
include SolidusStripe::LogEntries
|
|
23
|
+
|
|
24
|
+
def initialize(options)
|
|
25
|
+
# Cannot use kwargs because of how the Gateway is initialized by Solidus.
|
|
26
|
+
@client = Stripe::StripeClient.new(
|
|
27
|
+
api_key: options.fetch(:api_key, nil),
|
|
28
|
+
)
|
|
29
|
+
@options = options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :client
|
|
33
|
+
|
|
34
|
+
# Authorizes a certain amount on the provided payment source.
|
|
35
|
+
#
|
|
36
|
+
# We create and confirm the Stripe payment intent in two steps. That's to
|
|
37
|
+
# guarantee that we associate a Solidus payment on the creation, and we can
|
|
38
|
+
# fetch it after the webhook event is published on confirmation.
|
|
39
|
+
#
|
|
40
|
+
# The Stripe payment intent id is copied over the Solidus payment
|
|
41
|
+
# `response_code` field.
|
|
42
|
+
def authorize(amount_in_cents, _source, options = {})
|
|
43
|
+
check_given_amount_matches_payment_intent(amount_in_cents, options)
|
|
44
|
+
|
|
45
|
+
stripe_payment_intent_options = {
|
|
46
|
+
amount: to_stripe_amount(amount_in_cents, options[:originator].currency),
|
|
47
|
+
confirm: false,
|
|
48
|
+
capture_method: "manual",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stripe_payment_intent = SolidusStripe::PaymentIntent.prepare_for_payment(
|
|
52
|
+
options[:originator],
|
|
53
|
+
**stripe_payment_intent_options,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
confirm_stripe_payment_intent(stripe_payment_intent.stripe_intent_id)
|
|
57
|
+
|
|
58
|
+
build_payment_log(
|
|
59
|
+
success: true,
|
|
60
|
+
message: "PaymentIntent was confirmed successfully",
|
|
61
|
+
response_code: stripe_payment_intent.stripe_intent_id,
|
|
62
|
+
data: stripe_payment_intent.stripe_intent
|
|
63
|
+
)
|
|
64
|
+
rescue Stripe::StripeError => e
|
|
65
|
+
build_payment_log(
|
|
66
|
+
success: false,
|
|
67
|
+
message: e.message,
|
|
68
|
+
data: e.response
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Captures a certain amount from a previously authorized transaction.
|
|
73
|
+
#
|
|
74
|
+
# @see https://stripe.com/docs/api/payment_intents/capture#capture_payment_intent
|
|
75
|
+
# @see https://stripe.com/docs/payments/capture-later
|
|
76
|
+
#
|
|
77
|
+
# @todo add support for capturing custom amounts
|
|
78
|
+
def capture(amount_in_cents, payment_intent_id, options = {})
|
|
79
|
+
check_given_amount_matches_payment_intent(amount_in_cents, options)
|
|
80
|
+
check_payment_intent_id(payment_intent_id)
|
|
81
|
+
|
|
82
|
+
payment_intent = capture_stripe_payment_intent(payment_intent_id)
|
|
83
|
+
build_payment_log(
|
|
84
|
+
success: true,
|
|
85
|
+
message: "PaymentIntent was confirmed successfully",
|
|
86
|
+
response_code: payment_intent.id,
|
|
87
|
+
data: payment_intent,
|
|
88
|
+
)
|
|
89
|
+
rescue Stripe::InvalidRequestError => e
|
|
90
|
+
build_payment_log(
|
|
91
|
+
success: false,
|
|
92
|
+
message: e.to_s,
|
|
93
|
+
data: e.response,
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Authorizes and captures a certain amount on the provided payment source.
|
|
98
|
+
#
|
|
99
|
+
# See `#authorize` for how the confirmation step is performed.
|
|
100
|
+
#
|
|
101
|
+
# @todo add support for purchasing custom amounts
|
|
102
|
+
def purchase(amount_in_cents, _source, options = {})
|
|
103
|
+
check_given_amount_matches_payment_intent(amount_in_cents, options)
|
|
104
|
+
|
|
105
|
+
stripe_payment_intent_options = {
|
|
106
|
+
amount: to_stripe_amount(amount_in_cents, options[:originator].currency),
|
|
107
|
+
confirm: false,
|
|
108
|
+
capture_method: "automatic",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stripe_payment_intent = SolidusStripe::PaymentIntent.prepare_for_payment(
|
|
112
|
+
options[:originator],
|
|
113
|
+
**stripe_payment_intent_options,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
confirm_stripe_payment_intent(stripe_payment_intent.stripe_intent_id)
|
|
117
|
+
|
|
118
|
+
build_payment_log(
|
|
119
|
+
success: true,
|
|
120
|
+
message: "PaymentIntent was confirmed and captured successfully",
|
|
121
|
+
response_code: stripe_payment_intent.stripe_intent_id,
|
|
122
|
+
data: stripe_payment_intent.stripe_intent,
|
|
123
|
+
)
|
|
124
|
+
rescue Stripe::StripeError => e
|
|
125
|
+
build_payment_log(
|
|
126
|
+
success: false,
|
|
127
|
+
message: e.to_s,
|
|
128
|
+
data: e.response,
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Voids a previously authorized transaction, releasing the funds that are on hold.
|
|
133
|
+
def void(payment_intent_id, _options = {})
|
|
134
|
+
check_payment_intent_id(payment_intent_id)
|
|
135
|
+
|
|
136
|
+
payment_intent = request do
|
|
137
|
+
Stripe::PaymentIntent.cancel(payment_intent_id)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
build_payment_log(
|
|
141
|
+
success: true,
|
|
142
|
+
message: "PaymentIntent was canceled successfully",
|
|
143
|
+
response_code: payment_intent_id,
|
|
144
|
+
data: payment_intent,
|
|
145
|
+
)
|
|
146
|
+
rescue Stripe::InvalidRequestError => e
|
|
147
|
+
build_payment_log(
|
|
148
|
+
success: false,
|
|
149
|
+
message: e.to_s,
|
|
150
|
+
data: e.response,
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Refunds the provided amount on a previously captured transaction.
|
|
155
|
+
#
|
|
156
|
+
# Notice we're adding `solidus_skip_sync: 'true'` to the metadata to avoid a
|
|
157
|
+
# duplicated refund after the generated webhook event. See
|
|
158
|
+
# {RefundsSynchronizer}.
|
|
159
|
+
#
|
|
160
|
+
# TODO: check this method params twice.
|
|
161
|
+
def credit(amount_in_cents, payment_intent_id, options = {})
|
|
162
|
+
check_payment_intent_id(payment_intent_id)
|
|
163
|
+
|
|
164
|
+
payment = options[:originator].payment
|
|
165
|
+
currency = payment.currency
|
|
166
|
+
|
|
167
|
+
stripe_refund = request do
|
|
168
|
+
Stripe::Refund.create(
|
|
169
|
+
amount: to_stripe_amount(amount_in_cents, currency),
|
|
170
|
+
payment_intent: payment_intent_id,
|
|
171
|
+
metadata: {
|
|
172
|
+
RefundsSynchronizer::SKIP_SYNC_METADATA_KEY => RefundsSynchronizer::SKIP_SYNC_METADATA_VALUE
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
build_payment_log(
|
|
178
|
+
success: true,
|
|
179
|
+
message: "PaymentIntent was refunded successfully",
|
|
180
|
+
response_code: stripe_refund.id,
|
|
181
|
+
data: stripe_refund,
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Send a request to stripe using the current api keys but ignoring
|
|
186
|
+
# the response object.
|
|
187
|
+
#
|
|
188
|
+
# @yield Allows to use the `Stripe` gem using the credentials attached
|
|
189
|
+
# to the current payment method
|
|
190
|
+
#
|
|
191
|
+
# @example Retrieve a payment intent
|
|
192
|
+
# request { Stripe::PaymentIntent.retrieve(intent_id) }
|
|
193
|
+
#
|
|
194
|
+
# @return forwards the result of the block
|
|
195
|
+
def request(&block)
|
|
196
|
+
result, _response = client.request(&block)
|
|
197
|
+
result
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def confirm_stripe_payment_intent(stripe_payment_intent_id)
|
|
203
|
+
request { Stripe::PaymentIntent.confirm(stripe_payment_intent_id) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def capture_stripe_payment_intent(stripe_payment_intent_id)
|
|
207
|
+
request { Stripe::PaymentIntent.capture(stripe_payment_intent_id) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def check_given_amount_matches_payment_intent(amount_in_cents, options)
|
|
211
|
+
payment = options[:originator] or
|
|
212
|
+
raise ArgumentError, "please provide a payment with the :originator option"
|
|
213
|
+
|
|
214
|
+
return if amount_in_cents == payment.display_amount.cents
|
|
215
|
+
|
|
216
|
+
raise \
|
|
217
|
+
"Using a custom amount is not supported yet, " \
|
|
218
|
+
"tried #{amount_in_cents} but can only accept #{payment.display_amount.cents}."
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def check_payment_intent_id(payment_intent_id)
|
|
222
|
+
unless payment_intent_id
|
|
223
|
+
raise ArgumentError, "missing payment_intent_id"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
return if payment_intent_id.start_with?('pi_')
|
|
227
|
+
|
|
228
|
+
raise ArgumentError, "the payment intent id has the wrong format"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusStripe
|
|
4
|
+
class PaymentIntent < ApplicationRecord
|
|
5
|
+
belongs_to :order, class_name: 'Spree::Order'
|
|
6
|
+
belongs_to :payment_method, class_name: 'SolidusStripe::PaymentMethod'
|
|
7
|
+
|
|
8
|
+
def self.prepare_for_payment(payment, **stripe_intent_options)
|
|
9
|
+
# Find or create the intent for the payment.
|
|
10
|
+
intent =
|
|
11
|
+
retrieve_last_usable_intent(payment) ||
|
|
12
|
+
new(payment_method: payment.payment_method, order: payment.order)
|
|
13
|
+
.tap { _1.update!(stripe_intent_id: _1.create_stripe_intent(**stripe_intent_options).id) }
|
|
14
|
+
|
|
15
|
+
# Update the intent with the previously acquired payment method.
|
|
16
|
+
intent.payment_method.gateway.request {
|
|
17
|
+
Stripe::PaymentIntent.update(intent.stripe_intent_id, payment_method: payment.source.stripe_payment_method_id)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Attach the payment intent to the payment.
|
|
21
|
+
payment.update!(response_code: intent.stripe_intent.id)
|
|
22
|
+
|
|
23
|
+
intent
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.retrieve_last_usable_intent(payment)
|
|
27
|
+
intent = where(payment_method: payment.payment_method, order: payment.order).last
|
|
28
|
+
intent if intent&.usable?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def usable?
|
|
32
|
+
stripe_intent_id &&
|
|
33
|
+
stripe_intent.status == 'requires_payment_method'
|
|
34
|
+
stripe_intent.amount == stripe_order_amount
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stripe_order_amount
|
|
38
|
+
payment_method.gateway.to_stripe_amount(
|
|
39
|
+
order.display_order_total_after_store_credit.money.fractional,
|
|
40
|
+
order.currency,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def process_payment
|
|
45
|
+
payment = order.payments.valid.find_by!(
|
|
46
|
+
payment_method: payment_method,
|
|
47
|
+
response_code: stripe_intent.id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
payment.started_processing!
|
|
51
|
+
|
|
52
|
+
case stripe_intent.status
|
|
53
|
+
when 'requires_capture'
|
|
54
|
+
payment.pend! unless payment.pending?
|
|
55
|
+
successful = true
|
|
56
|
+
when 'succeeded'
|
|
57
|
+
payment.complete! unless payment.completed?
|
|
58
|
+
successful = true
|
|
59
|
+
else
|
|
60
|
+
payment.failure!
|
|
61
|
+
successful = false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
SolidusStripe::LogEntries.payment_log(
|
|
65
|
+
payment,
|
|
66
|
+
success: successful,
|
|
67
|
+
message: I18n.t("solidus_stripe.intent_status.#{stripe_intent.status}"),
|
|
68
|
+
data: stripe_intent,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if successful
|
|
72
|
+
order.complete!
|
|
73
|
+
order.user.wallet.add(payment.source) if order.user && stripe_intent.setup_future_usage.present?
|
|
74
|
+
else
|
|
75
|
+
order.payment_failed!
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
successful
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stripe_intent
|
|
82
|
+
@stripe_intent ||= payment_method.gateway.request do
|
|
83
|
+
Stripe::PaymentIntent.retrieve(stripe_intent_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reload(...)
|
|
88
|
+
@stripe_intent = nil
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create_stripe_intent(**stripe_intent_options)
|
|
93
|
+
stripe_customer_id = SolidusStripe::Customer.retrieve_or_create_stripe_customer_id(
|
|
94
|
+
payment_method: payment_method,
|
|
95
|
+
order: order
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
payment_method.gateway.request do
|
|
99
|
+
Stripe::PaymentIntent.create({
|
|
100
|
+
amount: stripe_order_amount,
|
|
101
|
+
currency: order.currency,
|
|
102
|
+
capture_method: payment_method.auto_capture? ? 'automatic' : 'manual',
|
|
103
|
+
setup_future_usage: payment_method.preferred_setup_future_usage.presence,
|
|
104
|
+
customer: stripe_customer_id,
|
|
105
|
+
metadata: { solidus_order_number: order.number },
|
|
106
|
+
**stripe_intent_options,
|
|
107
|
+
})
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusStripe
|
|
4
|
+
class PaymentMethod < ::Spree::PaymentMethod
|
|
5
|
+
preference :api_key, :string
|
|
6
|
+
preference :publishable_key, :string
|
|
7
|
+
preference :setup_future_usage, :string, default: ''
|
|
8
|
+
|
|
9
|
+
# @attribute [rw] preferred_webhook_endpoint_signing_secret The webhook endpoint signing secret
|
|
10
|
+
# for this payment method.
|
|
11
|
+
# @see https://stripe.com/docs/webhooks/signatures
|
|
12
|
+
preference :webhook_endpoint_signing_secret, :string
|
|
13
|
+
|
|
14
|
+
validates :preferred_setup_future_usage, inclusion: { in: ['', 'on_session', 'off_session'] }
|
|
15
|
+
|
|
16
|
+
has_one :slug_entry, class_name: 'SolidusStripe::SlugEntry', inverse_of: :payment_method, dependent: :destroy
|
|
17
|
+
|
|
18
|
+
after_create :assign_slug
|
|
19
|
+
|
|
20
|
+
delegate :slug, to: :slug_entry
|
|
21
|
+
|
|
22
|
+
# @return [Spree::RefundReason] the reason used for refunds
|
|
23
|
+
# generated from Stripe.
|
|
24
|
+
# @see SolidusStripe::Configuration.refund_reason_name
|
|
25
|
+
def self.refund_reason
|
|
26
|
+
Spree::RefundReason.find_by!(
|
|
27
|
+
name: SolidusStripe.configuration.refund_reason_name
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def partial_name
|
|
32
|
+
"stripe"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
alias cart_partial_name partial_name
|
|
36
|
+
alias product_page_partial_name partial_name
|
|
37
|
+
alias risky_partial_name partial_name
|
|
38
|
+
|
|
39
|
+
def source_required?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def payment_source_class
|
|
44
|
+
PaymentSource
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def gateway_class
|
|
48
|
+
Gateway
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def payment_profiles_supported?
|
|
52
|
+
# We actually support them, but not in the way expected by Solidus and its ActiveMerchant legacy.
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.with_slug(slug)
|
|
57
|
+
where(id: SlugEntry.where(slug: slug).select(:payment_method_id))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# TODO: re-evaluate the need for this and think of ways to always go throught the intent classes.
|
|
61
|
+
def self.intent_id_for_payment(payment)
|
|
62
|
+
return unless payment
|
|
63
|
+
|
|
64
|
+
payment.transaction_id || SolidusStripe::PaymentIntent.where(
|
|
65
|
+
order: payment.order, payment_method: payment.payment_method
|
|
66
|
+
)&.pick(:stripe_intent_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stripe_dashboard_url(intent_id)
|
|
70
|
+
path_prefix = '/test' if preferred_test_mode
|
|
71
|
+
|
|
72
|
+
case intent_id
|
|
73
|
+
when /^pi_/
|
|
74
|
+
"https://dashboard.stripe.com#{path_prefix}/payments/#{intent_id}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def assign_slug
|
|
79
|
+
# If there's only one payment method, we can use a default slug.
|
|
80
|
+
slug = preferred_test_mode ? 'test' : 'live' if self.class.count == 1
|
|
81
|
+
slug = SecureRandom.hex(16) while SlugEntry.exists?(slug: slug) || slug.nil?
|
|
82
|
+
|
|
83
|
+
create_slug_entry!(slug: slug)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The method that should be used is "Spree::PaymentMethod#reusable_sources".
|
|
87
|
+
# However, in the dedicated partial source form, the reusable_sources are
|
|
88
|
+
# assigned to "previous_cards":
|
|
89
|
+
# https://github.com/solidusio/solidus/blob/e9debb976e2228bb0b7a8eff4894e0556fc15cc8/backend/app/views/spree/admin/payments/_form.html.erb#L31
|
|
90
|
+
# This name is inaccurate and too specific because, in our case, a
|
|
91
|
+
# payment-source/stripe-payment-method have many different possible types:
|
|
92
|
+
# https://stripe.com/docs/api/payment_methods/object#payment_method_object-type
|
|
93
|
+
#
|
|
94
|
+
# For more details:
|
|
95
|
+
# https://github.com/solidusio/solidus/issues/5014
|
|
96
|
+
#
|
|
97
|
+
# @todo Start using the correct method to get a user's previous sources
|
|
98
|
+
def previous_sources(order)
|
|
99
|
+
if order.user_id
|
|
100
|
+
order.user.wallet.wallet_payment_sources.map(&:payment_source)
|
|
101
|
+
else
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stripe'
|
|
4
|
+
|
|
5
|
+
module SolidusStripe
|
|
6
|
+
class PaymentSource < ::Spree::PaymentSource
|
|
7
|
+
def stripe_payment_method
|
|
8
|
+
return if stripe_payment_method_id.blank?
|
|
9
|
+
|
|
10
|
+
@stripe_payment_method ||= payment_method.gateway.request do
|
|
11
|
+
Stripe::PaymentMethod.retrieve(stripe_payment_method_id)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def actions
|
|
16
|
+
%w[capture void credit]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def can_capture?(payment)
|
|
20
|
+
payment.pending?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def can_void?(payment)
|
|
24
|
+
payment.pending?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def can_credit?(payment)
|
|
28
|
+
payment.completed? && payment.credit_allowed > 0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusStripe
|
|
4
|
+
# Represents a webhook endpoint for a {SolidusStripe::PaymentMethod}.
|
|
5
|
+
#
|
|
6
|
+
# A Stripe webhook endpoint is a URL that Stripe will send events to. A store
|
|
7
|
+
# could have multiple Stripe payment methods (e.g., a marketplace), so we need
|
|
8
|
+
# to differentiate which one a webhook request targets.
|
|
9
|
+
#
|
|
10
|
+
# This model associates a slug with a payment method. The slug is appended
|
|
11
|
+
# to the endpoint URL (`.../webhooks/:slug`) so that we can fetch the
|
|
12
|
+
# correct payment method from the database and bind it to the generated
|
|
13
|
+
# `Spree::Bus` event.
|
|
14
|
+
#
|
|
15
|
+
# We use a slug instead of the payment method ID to be resilient to
|
|
16
|
+
# database changes and to avoid guessing about valid endpoint URLs.
|
|
17
|
+
class SlugEntry < ::Spree::Base
|
|
18
|
+
belongs_to :payment_method, class_name: 'SolidusStripe::PaymentMethod'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "solidus_stripe/refunds_synchronizer"
|
|
4
|
+
|
|
5
|
+
module SolidusStripe
|
|
6
|
+
module Webhook
|
|
7
|
+
# Handlers for Stripe charge events.
|
|
8
|
+
class ChargeSubscriber
|
|
9
|
+
include Omnes::Subscriber
|
|
10
|
+
include MoneyToStripeAmountConverter
|
|
11
|
+
|
|
12
|
+
handle :"stripe.charge.refunded", with: :sync_refunds
|
|
13
|
+
|
|
14
|
+
# Syncs Stripe refunds with Solidus refunds.
|
|
15
|
+
#
|
|
16
|
+
# @param event [SolidusStripe::Webhook::Event]
|
|
17
|
+
# @see SolidusStripe::RefundsSynchronizer
|
|
18
|
+
def sync_refunds(event)
|
|
19
|
+
payment_method = event.spree_payment_method
|
|
20
|
+
payment_intent_id = event.data.object.payment_intent
|
|
21
|
+
|
|
22
|
+
RefundsSynchronizer
|
|
23
|
+
.new(payment_method)
|
|
24
|
+
.call(payment_intent_id)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "solidus_stripe/refunds_synchronizer"
|
|
4
|
+
|
|
5
|
+
module SolidusStripe
|
|
6
|
+
module Webhook
|
|
7
|
+
# Handlers for Stripe payment_intent events.
|
|
8
|
+
class PaymentIntentSubscriber
|
|
9
|
+
include Omnes::Subscriber
|
|
10
|
+
include SolidusStripe::MoneyToStripeAmountConverter
|
|
11
|
+
|
|
12
|
+
handle :"stripe.payment_intent.succeeded", with: :capture_payment
|
|
13
|
+
handle :"stripe.payment_intent.payment_failed", with: :fail_payment
|
|
14
|
+
handle :"stripe.payment_intent.canceled", with: :void_payment
|
|
15
|
+
|
|
16
|
+
# Captures a payment.
|
|
17
|
+
#
|
|
18
|
+
# Marks a Solidus payment associated to a Stripe payment intent as
|
|
19
|
+
# completed, adding a log entry about the event.
|
|
20
|
+
#
|
|
21
|
+
# In the case of a partial capture, it also synchronizes the refunds.
|
|
22
|
+
#
|
|
23
|
+
# @param event [SolidusStripe::Webhook::Event]
|
|
24
|
+
# @see SolidusStripe::RefundsSynchronizer
|
|
25
|
+
def capture_payment(event)
|
|
26
|
+
payment = extract_payment_from_event(event)
|
|
27
|
+
payment.with_lock do
|
|
28
|
+
break false if payment.completed?
|
|
29
|
+
|
|
30
|
+
complete_payment(payment)
|
|
31
|
+
end && sync_refunds(event)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Fails a payment.
|
|
35
|
+
#
|
|
36
|
+
# Marks a Solidus payment associated to a Stripe payment intent as
|
|
37
|
+
# failed, adding a log entry about the event.
|
|
38
|
+
#
|
|
39
|
+
# @param event [SolidusStripe::Webhook::Event]
|
|
40
|
+
def fail_payment(event)
|
|
41
|
+
payment = extract_payment_from_event(event)
|
|
42
|
+
|
|
43
|
+
payment.with_lock do
|
|
44
|
+
break if payment.failed?
|
|
45
|
+
|
|
46
|
+
payment.failure!.tap do
|
|
47
|
+
SolidusStripe::LogEntries.payment_log(
|
|
48
|
+
payment,
|
|
49
|
+
success: false,
|
|
50
|
+
message: "Payment was marked as failed after payment_intent.failed webhook"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Voids a payment.
|
|
57
|
+
#
|
|
58
|
+
# Voids a Solidus payment associated to a Stripe payment intent, adding a
|
|
59
|
+
# log entry about the event.
|
|
60
|
+
#
|
|
61
|
+
# @param event [SolidusStripe::Webhook::Event]
|
|
62
|
+
def void_payment(event)
|
|
63
|
+
payment = extract_payment_from_event(event)
|
|
64
|
+
reason = event.data.object.cancellation_reason
|
|
65
|
+
|
|
66
|
+
payment.with_lock do
|
|
67
|
+
break if payment.void?
|
|
68
|
+
|
|
69
|
+
payment.void!.tap do
|
|
70
|
+
SolidusStripe::LogEntries.payment_log(
|
|
71
|
+
payment,
|
|
72
|
+
success: true,
|
|
73
|
+
message: "Payment was voided after payment_intent.voided webhook (#{reason})"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def extract_payment_from_event(event)
|
|
82
|
+
payment_intent_id = event.data.object.id
|
|
83
|
+
Spree::Payment.find_by!(response_code: payment_intent_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def complete_payment(payment)
|
|
87
|
+
payment.complete!.tap do
|
|
88
|
+
SolidusStripe::LogEntries.payment_log(
|
|
89
|
+
payment,
|
|
90
|
+
success: true,
|
|
91
|
+
message: "Capture was successful after payment_intent.succeeded webhook"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def sync_refunds(event)
|
|
97
|
+
event.data.object.to_hash => {
|
|
98
|
+
id: payment_intent_id,
|
|
99
|
+
amount: stripe_amount,
|
|
100
|
+
amount_received: stripe_amount_received,
|
|
101
|
+
currency:
|
|
102
|
+
}
|
|
103
|
+
return if stripe_amount == stripe_amount_received
|
|
104
|
+
|
|
105
|
+
payment_method = event.spree_payment_method
|
|
106
|
+
RefundsSynchronizer
|
|
107
|
+
.new(payment_method)
|
|
108
|
+
.call(payment_intent_id)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<fieldset>
|
|
2
|
+
<legend><%= payment_method.name %></legend>
|
|
3
|
+
|
|
4
|
+
<% previous_sources = payment_method.previous_sources(@order) %>
|
|
5
|
+
|
|
6
|
+
<ul>
|
|
7
|
+
<% previous_sources.each do |payment_source| %>
|
|
8
|
+
<% default = payment_source == previous_sources.first %>
|
|
9
|
+
<% stripe_payment_method = payment_source.stripe_payment_method %>
|
|
10
|
+
|
|
11
|
+
<li>
|
|
12
|
+
<label>
|
|
13
|
+
<%= radio_button_tag(
|
|
14
|
+
:card,
|
|
15
|
+
payment_source.id,
|
|
16
|
+
default
|
|
17
|
+
) %>
|
|
18
|
+
<%= stripe_payment_method.type.humanize %>
|
|
19
|
+
</label>
|
|
20
|
+
<fieldset>
|
|
21
|
+
<%= render(
|
|
22
|
+
"spree/admin/payments/source_forms/existing_payment/#{payment_method.partial_name}",
|
|
23
|
+
stripe_payment_method: stripe_payment_method,
|
|
24
|
+
) %>
|
|
25
|
+
</fieldset>
|
|
26
|
+
</li>
|
|
27
|
+
<% end %>
|
|
28
|
+
</ul>
|
|
29
|
+
</fieldset>
|