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