solidus_stripe 4.3.0 → 5.0.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (153) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +98 -23
  3. data/.github/stale.yml +1 -17
  4. data/.gitignore +3 -0
  5. data/.rubocop.yml +94 -2
  6. data/.yardopts +1 -0
  7. data/CHANGELOG.md +1 -231
  8. data/Gemfile +10 -25
  9. data/LICENSE +2 -2
  10. data/Procfile.dev +3 -0
  11. data/README.md +155 -223
  12. data/Rakefile +7 -3
  13. data/app/assets/javascripts/spree/backend/solidus_stripe.js +2 -0
  14. data/app/assets/stylesheets/spree/backend/solidus_stripe.css +4 -0
  15. data/app/controllers/solidus_stripe/intents_controller.rb +36 -52
  16. data/app/controllers/solidus_stripe/webhooks_controller.rb +28 -0
  17. data/app/models/concerns/solidus_stripe/log_entries.rb +31 -0
  18. data/app/models/solidus_stripe/customer.rb +21 -0
  19. data/app/models/solidus_stripe/gateway.rb +231 -0
  20. data/app/models/solidus_stripe/payment_intent.rb +111 -0
  21. data/app/models/solidus_stripe/payment_method.rb +106 -0
  22. data/app/models/solidus_stripe/payment_source.rb +31 -0
  23. data/app/models/solidus_stripe/slug_entry.rb +20 -0
  24. data/app/models/solidus_stripe.rb +7 -0
  25. data/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb +28 -0
  26. data/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb +112 -0
  27. data/app/views/spree/admin/payments/source_forms/_stripe.html.erb +29 -0
  28. data/app/views/spree/admin/payments/source_forms/existing_payment/_stripe.html.erb +14 -0
  29. data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_card.html.erb +8 -0
  30. data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_default.html.erb +7 -0
  31. data/app/views/spree/admin/payments/source_views/_stripe.html.erb +15 -0
  32. data/app/views/spree/api/payments/source_views/_stripe.json.jbuilder +8 -0
  33. data/bin/dev +13 -0
  34. data/bin/dummy-app +29 -0
  35. data/bin/rails +38 -3
  36. data/bin/rails-dummy-app +3 -0
  37. data/bin/rails-engine +1 -11
  38. data/bin/rails-new +55 -0
  39. data/bin/rails-sandbox +1 -14
  40. data/bin/rspec +10 -0
  41. data/bin/sandbox +12 -74
  42. data/bin/setup +1 -0
  43. data/bin/update-migrations +56 -0
  44. data/codecov.yml +12 -0
  45. data/config/locales/en.yml +16 -1
  46. data/config/routes.rb +5 -11
  47. data/coverage.rb +42 -0
  48. data/db/migrate/20230109183332_create_solidus_stripe_payment_sources.rb +10 -0
  49. data/db/migrate/20230303154931_create_solidus_stripe_setup_intent.rb +10 -0
  50. data/db/migrate/20230306105520_create_solidus_stripe_payment_intents.rb +10 -0
  51. data/db/migrate/20230308122414_create_solidus_stripe_webhook_endpoints.rb +10 -0
  52. data/db/migrate/20230310152615_add_payment_method_reference_to_stripe_intents.rb +6 -0
  53. data/db/migrate/20230310171444_normalize_stripe_intent_id_attributes.rb +6 -0
  54. data/db/migrate/20230313150008_create_solidus_stripe_customers.rb +16 -0
  55. data/db/migrate/20230323154931_drop_solidus_stripe_setup_intent.rb +13 -0
  56. data/db/migrate/20230403094916_rename_webhook_endpoint_to_payment_method_slug_entries.rb +5 -0
  57. data/db/seeds.rb +6 -24
  58. data/lib/generators/solidus_stripe/install/install_generator.rb +121 -14
  59. data/lib/generators/solidus_stripe/install/templates/app/assets/stylesheets/spree/frontend/solidus_stripe.css +13 -0
  60. data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js +39 -0
  61. data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_payment_controller.js +89 -0
  62. data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/_stripe.html.erb +16 -0
  63. data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_card.html.erb +8 -0
  64. data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_default.html.erb +7 -0
  65. data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +39 -0
  66. data/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb +20 -0
  67. data/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb +31 -0
  68. data/lib/solidus_stripe/configuration.rb +24 -3
  69. data/lib/solidus_stripe/engine.rb +19 -6
  70. data/lib/solidus_stripe/money_to_stripe_amount_converter.rb +109 -0
  71. data/lib/solidus_stripe/refunds_synchronizer.rb +96 -0
  72. data/lib/solidus_stripe/seeds.rb +19 -0
  73. data/lib/solidus_stripe/testing_support/factories.rb +153 -0
  74. data/lib/solidus_stripe/version.rb +1 -1
  75. data/lib/solidus_stripe/webhook/event.rb +90 -0
  76. data/lib/solidus_stripe.rb +0 -2
  77. data/solidus_stripe.gemspec +29 -5
  78. data/spec/lib/solidus_stripe/configuration_spec.rb +21 -0
  79. data/spec/lib/solidus_stripe/money_to_stripe_amount_converter_spec.rb +133 -0
  80. data/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb +238 -0
  81. data/spec/lib/solidus_stripe/seeds_spec.rb +43 -0
  82. data/spec/lib/solidus_stripe/webhook/event_spec.rb +134 -0
  83. data/spec/models/concerns/solidus_stripe/log_entries_spec.rb +54 -0
  84. data/spec/models/solidus_stripe/customer_spec.rb +47 -0
  85. data/spec/models/solidus_stripe/gateway_spec.rb +283 -0
  86. data/spec/models/solidus_stripe/payment_intent_spec.rb +17 -0
  87. data/spec/models/solidus_stripe/payment_method_spec.rb +137 -0
  88. data/spec/models/solidus_stripe/payment_source_spec.rb +25 -0
  89. data/spec/requests/solidus_stripe/intents_controller_spec.rb +29 -0
  90. data/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb +31 -0
  91. data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/canceled_spec.rb +23 -0
  92. data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/payment_failed_spec.rb +23 -0
  93. data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/succeeded_spec.rb +29 -0
  94. data/spec/requests/solidus_stripe/webhooks_controller_spec.rb +52 -0
  95. data/spec/solidus_stripe_spec_helper.rb +10 -0
  96. data/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb +33 -0
  97. data/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb +297 -0
  98. data/spec/support/solidus_stripe/backend_test_helper.rb +210 -0
  99. data/spec/support/solidus_stripe/checkout_test_helper.rb +339 -0
  100. data/spec/support/solidus_stripe/factories.rb +5 -0
  101. data/spec/support/solidus_stripe/webhook/data_fixtures.rb +106 -0
  102. data/spec/support/solidus_stripe/webhook/event_with_context_factory.rb +82 -0
  103. data/spec/support/solidus_stripe/webhook/request_helper.rb +32 -0
  104. data/spec/system/backend/solidus_stripe/orders/payments_spec.rb +119 -0
  105. data/spec/system/frontend/.keep +0 -0
  106. data/spec/system/frontend/solidus_stripe/checkout_spec.rb +187 -0
  107. data/tmp/.keep +0 -0
  108. metadata +210 -69
  109. data/.travis.yml +0 -28
  110. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-cart-page-checkout.js +0 -122
  111. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-elements.js +0 -148
  112. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-init.js +0 -20
  113. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-intents.js +0 -84
  114. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-request-button-shared.js +0 -160
  115. data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment.js +0 -16
  116. data/app/assets/javascripts/spree/frontend/solidus_stripe.js +0 -6
  117. data/app/controllers/solidus_stripe/payment_request_controller.rb +0 -52
  118. data/app/controllers/spree/stripe_controller.rb +0 -13
  119. data/app/decorators/models/spree/order_update_attributes_decorator.rb +0 -39
  120. data/app/decorators/models/spree/payment_decorator.rb +0 -11
  121. data/app/decorators/models/spree/refund_decorator.rb +0 -9
  122. data/app/models/solidus_stripe/address_from_params_service.rb +0 -72
  123. data/app/models/solidus_stripe/create_intents_payment_service.rb +0 -114
  124. data/app/models/solidus_stripe/prepare_order_for_payment_service.rb +0 -46
  125. data/app/models/solidus_stripe/shipping_rates_service.rb +0 -46
  126. data/app/models/spree/payment_method/stripe_credit_card.rb +0 -217
  127. data/bin/r +0 -13
  128. data/bin/sandbox_rails +0 -18
  129. data/db/migrate/20181010123508_update_stripe_payment_method_type_to_credit_card.rb +0 -21
  130. data/lib/assets/stylesheets/spree/frontend/solidus_stripe.scss +0 -11
  131. data/lib/solidus_stripe/testing_support/card_input_helper.rb +0 -34
  132. data/lib/tasks/solidus_stripe/db/seed.rake +0 -14
  133. data/lib/views/api/spree/api/payments/source_views/_stripe.json.jbuilder +0 -3
  134. data/lib/views/backend/spree/admin/log_entries/_stripe.html.erb +0 -28
  135. data/lib/views/backend/spree/admin/payments/source_forms/_stripe.html.erb +0 -1
  136. data/lib/views/backend/spree/admin/payments/source_views/_stripe.html.erb +0 -1
  137. data/lib/views/frontend/spree/checkout/existing_payment/_stripe.html.erb +0 -1
  138. data/lib/views/frontend/spree/checkout/payment/_stripe.html.erb +0 -8
  139. data/lib/views/frontend/spree/checkout/payment/v2/_javascript.html.erb +0 -78
  140. data/lib/views/frontend/spree/checkout/payment/v3/_elements.html.erb +0 -1
  141. data/lib/views/frontend/spree/checkout/payment/v3/_form_elements.html.erb +0 -40
  142. data/lib/views/frontend/spree/checkout/payment/v3/_intents.html.erb +0 -1
  143. data/lib/views/frontend/spree/checkout/payment/v3/_stripe.html.erb +0 -2
  144. data/lib/views/frontend/spree/orders/_stripe_payment_request_button.html.erb +0 -14
  145. data/spec/features/stripe_checkout_spec.rb +0 -486
  146. data/spec/models/solidus_stripe/address_from_params_service_spec.rb +0 -87
  147. data/spec/models/solidus_stripe/create_intents_payment_service_spec.rb +0 -127
  148. data/spec/models/solidus_stripe/prepare_order_for_payment_service_spec.rb +0 -65
  149. data/spec/models/solidus_stripe/shipping_rates_service_spec.rb +0 -54
  150. data/spec/models/spree/payment_method/stripe_credit_card_spec.rb +0 -316
  151. data/spec/requests/payment_requests_spec.rb +0 -152
  152. data/spec/spec_helper.rb +0 -37
  153. data/spec/support/solidus_address_helper.rb +0 -15
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusStripe
4
+ def self.table_name_prefix
5
+ "solidus_stripe_"
6
+ end
7
+ 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>
@@ -0,0 +1,14 @@
1
+ <%
2
+ # https://stripe.com/docs/api/payment_methods/object#payment_method_object-type
3
+ partial_base = "spree/admin/payments/source_forms/existing_payment/stripe"
4
+ payment_type = stripe_payment_method.type
5
+
6
+ # Fallback on the default partial if a specialized partial is not available.
7
+ payment_type = 'default' if lookup_context.find_all("#{partial_base}/_#{payment_type}").none?
8
+ %>
9
+
10
+ <div>
11
+ <label>
12
+ <%= render "#{partial_base}/#{payment_type}", stripe_payment_method: stripe_payment_method %>
13
+ </label>
14
+ </div>