spree_stripe 1.0.2

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 (100) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +14 -0
  3. data/README.md +132 -0
  4. data/Rakefile +21 -0
  5. data/app/assets/config/spree_stripe_manifest.js +3 -0
  6. data/app/controllers/spree/api/v2/storefront/stripe/base_controller.rb +21 -0
  7. data/app/controllers/spree/api/v2/storefront/stripe/payment_intents_controller.rb +61 -0
  8. data/app/controllers/spree/api/v2/storefront/stripe/setup_intents_controller.rb +19 -0
  9. data/app/controllers/spree_stripe/apple_pay_domain_verification_controller.rb +11 -0
  10. data/app/controllers/spree_stripe/payment_intents_controller.rb +61 -0
  11. data/app/controllers/spree_stripe/store_controller_decorator.rb +9 -0
  12. data/app/controllers/stripe_event/webhook_controller_decorator.rb +17 -0
  13. data/app/helpers/spree_stripe/base_helper.rb +13 -0
  14. data/app/javascript/spree_stripe/application.js +18 -0
  15. data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +452 -0
  16. data/app/javascript/spree_stripe/controllers/stripe_controller.js +311 -0
  17. data/app/jobs/spree_stripe/base_job.rb +5 -0
  18. data/app/jobs/spree_stripe/complete_order_job.rb +8 -0
  19. data/app/jobs/spree_stripe/create_webhook_endpoint_job.rb +7 -0
  20. data/app/jobs/spree_stripe/register_domain_job.rb +20 -0
  21. data/app/models/spree_stripe/base.rb +6 -0
  22. data/app/models/spree_stripe/credit_card_decorator.rb +13 -0
  23. data/app/models/spree_stripe/custom_domain_decorator.rb +18 -0
  24. data/app/models/spree_stripe/gateway.rb +366 -0
  25. data/app/models/spree_stripe/order_decorator.rb +19 -0
  26. data/app/models/spree_stripe/payment_decorator.rb +46 -0
  27. data/app/models/spree_stripe/payment_intent.rb +66 -0
  28. data/app/models/spree_stripe/payment_method_decorator.rb +18 -0
  29. data/app/models/spree_stripe/payment_methods_webhook_key.rb +8 -0
  30. data/app/models/spree_stripe/payment_source_decorator.rb +9 -0
  31. data/app/models/spree_stripe/payment_sources/affirm.rb +9 -0
  32. data/app/models/spree_stripe/payment_sources/after_pay.rb +13 -0
  33. data/app/models/spree_stripe/payment_sources/alipay.rb +9 -0
  34. data/app/models/spree_stripe/payment_sources/ideal.rb +15 -0
  35. data/app/models/spree_stripe/payment_sources/klarna.rb +9 -0
  36. data/app/models/spree_stripe/payment_sources/link.rb +13 -0
  37. data/app/models/spree_stripe/payment_sources/przelewy24.rb +11 -0
  38. data/app/models/spree_stripe/payment_sources/sepa_debit.rb +13 -0
  39. data/app/models/spree_stripe/store_decorator.rb +24 -0
  40. data/app/models/spree_stripe/webhook_key.rb +14 -0
  41. data/app/presenters/spree_stripe/customer_presenter.rb +35 -0
  42. data/app/presenters/spree_stripe/payment_intent_presenter.rb +103 -0
  43. data/app/serializers/spree/api/v2/platform/affirm_serializer.rb +9 -0
  44. data/app/serializers/spree/api/v2/platform/after_pay_serializer.rb +9 -0
  45. data/app/serializers/spree/api/v2/platform/alipay_serializer.rb +9 -0
  46. data/app/serializers/spree/api/v2/platform/ideal_serializer.rb +9 -0
  47. data/app/serializers/spree/api/v2/platform/klarna_serializer.rb +9 -0
  48. data/app/serializers/spree/api/v2/platform/link_serializer.rb +9 -0
  49. data/app/serializers/spree/api/v2/platform/przelewy24_serializer.rb +9 -0
  50. data/app/serializers/spree/api/v2/platform/sepa_debit_serializer.rb +9 -0
  51. data/app/serializers/spree/v2/storefront/affirm_serializer.rb +7 -0
  52. data/app/serializers/spree/v2/storefront/after_pay_serializer.rb +7 -0
  53. data/app/serializers/spree/v2/storefront/alipay_serializer.rb +7 -0
  54. data/app/serializers/spree/v2/storefront/ideal_serializer.rb +7 -0
  55. data/app/serializers/spree/v2/storefront/klarna_serializer.rb +7 -0
  56. data/app/serializers/spree/v2/storefront/link_serializer.rb +7 -0
  57. data/app/serializers/spree/v2/storefront/payment_intent_serializer.rb +11 -0
  58. data/app/serializers/spree/v2/storefront/przelewy24_serializer.rb +7 -0
  59. data/app/serializers/spree/v2/storefront/sepa_debit_serializer.rb +7 -0
  60. data/app/services/spree_stripe/complete_order.rb +99 -0
  61. data/app/services/spree_stripe/create_gateway_webhooks.rb +55 -0
  62. data/app/services/spree_stripe/create_payment.rb +43 -0
  63. data/app/services/spree_stripe/create_payment_intent.rb +40 -0
  64. data/app/services/spree_stripe/create_setup_intent.rb +20 -0
  65. data/app/services/spree_stripe/create_source.rb +87 -0
  66. data/app/services/spree_stripe/register_domain.rb +22 -0
  67. data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +18 -0
  68. data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +13 -0
  69. data/app/services/spree_stripe/webhook_handlers/setup_intent_succeeded.rb +34 -0
  70. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_stripe.html.erb +0 -0
  71. data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_stripe.html.erb +47 -0
  72. data/app/views/spree/admin/payment_methods/descriptions/_spree_stripe.html.erb +15 -0
  73. data/app/views/spree/checkout/payment/_spree_stripe.html.erb +63 -0
  74. data/app/views/spree_stripe/_head.html.erb +2 -0
  75. data/app/views/spree_stripe/_quick_checkout.html.erb +34 -0
  76. data/config/importmap.rb +8 -0
  77. data/config/initializers/spree.rb +8 -0
  78. data/config/initializers/stripe.rb +14 -0
  79. data/config/locales/en.yml +5 -0
  80. data/config/routes.rb +24 -0
  81. data/db/migrate/20250310152812_setup_spree_stripe_models.rb +41 -0
  82. data/lib/generators/spree_stripe/install/install_generator.rb +20 -0
  83. data/lib/spree_stripe/configuration.rb +5 -0
  84. data/lib/spree_stripe/engine.rb +36 -0
  85. data/lib/spree_stripe/factories.rb +3 -0
  86. data/lib/spree_stripe/testing_support/factories/after_pay_payment_source_factory.rb +6 -0
  87. data/lib/spree_stripe/testing_support/factories/alipay_payment_source_factory.rb +6 -0
  88. data/lib/spree_stripe/testing_support/factories/gateway_factory.rb +23 -0
  89. data/lib/spree_stripe/testing_support/factories/ideal_payment_source_factory.rb +6 -0
  90. data/lib/spree_stripe/testing_support/factories/klarna_payment_source_factory.rb +6 -0
  91. data/lib/spree_stripe/testing_support/factories/link_payment_source_factory.rb +6 -0
  92. data/lib/spree_stripe/testing_support/factories/payment_intent_factory.rb +24 -0
  93. data/lib/spree_stripe/testing_support/factories/payment_source_factory.rb +8 -0
  94. data/lib/spree_stripe/testing_support/factories/przelewy24_payment_source_factory.rb +6 -0
  95. data/lib/spree_stripe/testing_support/factories/sepa_debit_payment_source_factory.rb +6 -0
  96. data/lib/spree_stripe/testing_support/factories/webhook_key_factory.rb +23 -0
  97. data/lib/spree_stripe/version.rb +7 -0
  98. data/lib/spree_stripe.rb +14 -0
  99. data/vendor/javascript/@stripe--stripe-js--dist--pure.esm.js.js +4 -0
  100. metadata +295 -0
@@ -0,0 +1,311 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import showFlashMessage from 'spree/storefront/helpers/show_flash_message'
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ apiKey: String,
7
+ paymentIntent: Object,
8
+ returnUrl: String,
9
+ orderEmail: String,
10
+ orderToken: String,
11
+ colorPrimary: String,
12
+ colorBackground: String,
13
+ colorText: String
14
+ }
15
+
16
+ static targets = [
17
+ 'paymentElement',
18
+ 'loading',
19
+ 'spinner',
20
+ 'buttonText',
21
+ 'defaultCard'
22
+ ]
23
+
24
+ connect() {
25
+ const stripeOptions = {}
26
+
27
+ this.submitTarget = document.querySelector('#checkout-payment-submit')
28
+ this.billingAddressCheckbox = document.querySelector('#order_use_shipping')
29
+ this.billingAddressForm = document.querySelector('form.edit_order')
30
+ this.stripe = Stripe(this.apiKeyValue, stripeOptions)
31
+
32
+ if (this.hasDefaultCardTarget) {
33
+ this.initializeStripe({
34
+ target: { value: this.defaultCardTarget.value }
35
+ })
36
+ } else {
37
+ this.initializeStripe({ target: { value: 'on' } })
38
+ }
39
+
40
+ // hijack the submit button to call the submit method
41
+ this.submitTarget.addEventListener('click', this.submit.bind(this))
42
+ }
43
+
44
+ // Fetches a payment intent
45
+ async initializeStripe(e) {
46
+ this.setLoading(true)
47
+
48
+ if (e.target.value == 'on') {
49
+ this.stripePaymentMethodId = null
50
+ } else {
51
+ this.stripePaymentMethodId = e.target.value
52
+ }
53
+
54
+ const response = await fetch(
55
+ `/api/v2/storefront/stripe/payment_intents/${this.paymentIntentValue.id}`,
56
+ {
57
+ method: 'PATCH',
58
+ headers: this.spreeApiHeaders,
59
+ body: JSON.stringify({
60
+ payment_intent: {
61
+ stripe_payment_method_id: this.stripePaymentMethodId
62
+ }
63
+ })
64
+ }
65
+ )
66
+ const { data, error } = await response.json()
67
+
68
+ if (error) {
69
+ this.handleError(error)
70
+ return
71
+ }
72
+
73
+ if (this.stripePaymentMethodId === null) {
74
+ const appearance = {
75
+ theme: 'stripe',
76
+ variables: {
77
+ colorPrimary: this.colorPrimaryValue,
78
+ colorBackground: this.colorBackgroundValue,
79
+ colorText: this.colorTextValue
80
+ }
81
+ }
82
+ this.elements = this.stripe.elements({
83
+ appearance,
84
+ clientSecret: this.paymentIntentValue.client_secret
85
+ })
86
+ const paymentElement = this.elements.create('payment', {
87
+ fields: {
88
+ billingDetails: {
89
+ name: 'never',
90
+ email: 'never',
91
+ address: {
92
+ country: 'never',
93
+ postalCode: 'never'
94
+ }
95
+ }
96
+ }
97
+ })
98
+ paymentElement.mount(this.paymentElementTarget)
99
+ paymentElement.on('change', (event) => {
100
+ this.selectedMethod = event.value?.type
101
+ })
102
+ }
103
+
104
+ this.setLoading(false)
105
+ }
106
+
107
+ async submit(e) {
108
+ e.preventDefault()
109
+ this.setLoading(true)
110
+
111
+ const validated = await this.validateOrderForPayment()
112
+
113
+ if (!validated) {
114
+ this.setLoading(false)
115
+ e.stopImmediatePropagation()
116
+ return
117
+ }
118
+
119
+ const billingAddress = await this.updateBillingAddress()
120
+
121
+ if (!billingAddress) {
122
+ this.setLoading(false)
123
+ e.stopImmediatePropagation()
124
+ return
125
+ }
126
+
127
+ if (this.stripePaymentMethodId) {
128
+ const { paymentIntent, error } = await this.stripe.confirmCardPayment(
129
+ this.paymentIntentValue.client_secret
130
+ )
131
+
132
+ this.handleError(error)
133
+
134
+ window.location.href = this.returnUrlValue
135
+ } else {
136
+ const elements = this.elements
137
+
138
+ if (!elements || !elements.getElement('payment')) {
139
+ showFlashMessage(
140
+ 'An unexpected error occurred. Please refresh the page and try again.',
141
+ 'error'
142
+ )
143
+ return
144
+ }
145
+
146
+ const { error } = await this.stripe.confirmPayment({
147
+ elements,
148
+ confirmParams: {
149
+ payment_method_data: {
150
+ billing_details: {
151
+ email: this.orderEmailValue,
152
+ name: billingAddress.firstname + ' ' + billingAddress.lastname,
153
+ address: {
154
+ city: billingAddress.city,
155
+ country: billingAddress.country_iso,
156
+ line1: billingAddress.address1,
157
+ line2: billingAddress.address2,
158
+ postal_code: billingAddress.zipcode,
159
+ state: billingAddress.state_code
160
+ }
161
+ }
162
+ },
163
+ return_url: this.returnUrlValue
164
+ }
165
+ })
166
+
167
+ // This point will only be reached if there is an immediate error when
168
+ // confirming the payment. Otherwise, your customer will be redirected to
169
+ // your `return_url`. For some payment methods like iDEAL, your customer will
170
+ // be redirected to an intermediate site first to authorize the payment, then
171
+ // redirected to the `return_url`.
172
+ // TODO: we need to remove the payment intent id from this order and create new
173
+ this.handleError(error)
174
+ }
175
+
176
+ this.setLoading(false)
177
+ }
178
+
179
+ async validateOrderForPayment() {
180
+ const response = await fetch(
181
+ '/api/v2/storefront/checkout/validate_order_for_payment',
182
+ {
183
+ method: 'POST',
184
+ headers: this.spreeApiHeaders
185
+ }
186
+ )
187
+
188
+ const json = await response.json()
189
+
190
+ if (json.meta?.messages?.length) {
191
+ const message = [
192
+ json.meta.messages,
193
+ 'please refresh the page and try again.'
194
+ ].join(', ')
195
+
196
+ showFlashMessage(message, 'error')
197
+ return false
198
+ } else {
199
+ return true
200
+ }
201
+ }
202
+
203
+ async updateBillingAddress() {
204
+ // billing address same as shipping address
205
+ if (this.billingAddressCheckbox.checked) {
206
+ const response = await fetch('/api/v2/storefront/checkout?include=billing_address', {
207
+ method: 'PATCH',
208
+ headers: this.spreeApiHeaders,
209
+ body: JSON.stringify({
210
+ order: {
211
+ use_shipping: true
212
+ }
213
+ })
214
+ });
215
+
216
+ const responseJson = await response.json();
217
+
218
+ if (response.ok) {
219
+ return responseJson.included.find(item => item.type === 'address').attributes;
220
+ } else {
221
+ this.submitTarget.disabled = false;
222
+ return false;
223
+ }
224
+ }
225
+
226
+ // billing address different from shipping address
227
+ if (this.billingAddressForm.checkValidity()) {
228
+ const formData = new FormData(this.billingAddressForm);
229
+
230
+ const response = await fetch('/api/v2/storefront/checkout?include=billing_address', {
231
+ method: 'PATCH',
232
+ headers: this.spreeApiHeaders,
233
+ body: JSON.stringify({
234
+ order: {
235
+ bill_address_attributes: {
236
+ firstname: formData.get("order[bill_address_attributes][firstname]"),
237
+ lastname: formData.get("order[bill_address_attributes][lastname]"),
238
+ address1: formData.get("order[bill_address_attributes][address1]"),
239
+ address2: formData.get("order[bill_address_attributes][address2]"),
240
+ city: formData.get("order[bill_address_attributes][city]"),
241
+ country_id: formData.get("order[bill_address_attributes][country_id]"),
242
+ state_id: formData.get("order[bill_address_attributes][state_id]"),
243
+ state_name: formData.get("order[bill_address_attributes][state_name]"),
244
+ zipcode: formData.get("order[bill_address_attributes][zipcode]"),
245
+ phone: formData.get("order[bill_address_attributes][phone]")
246
+ }
247
+ }
248
+ })
249
+ });
250
+
251
+ const responseJson = await response.json();
252
+
253
+ if (response.ok) {
254
+ return responseJson.included.find(item => item.type === 'address').attributes;
255
+ } else {
256
+ const errors = Array.isArray(responseJson.error) ? responseJson.error.join('. ') : responseJson.error || 'Billing address is invalid';
257
+ showFlashMessage(errors, 'error');
258
+
259
+ const flashMessage = document.getElementsByClassName('flash-message')[0];
260
+ if (flashMessage) {
261
+ flashMessage.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
262
+ }
263
+
264
+ this.submitTarget.disabled = false;
265
+ return false;
266
+ }
267
+ } else {
268
+ this.submitTarget.disabled = false;
269
+ return false
270
+ }
271
+ }
272
+
273
+ handleError(error) {
274
+ if (error) {
275
+ if (error.type === 'card_error' || error.type === 'validation_error') {
276
+ showFlashMessage(error.message, 'error')
277
+ } else {
278
+ showFlashMessage(
279
+ 'An unexpected error occured. Please refresh the page and try again.',
280
+ 'error'
281
+ )
282
+ }
283
+
284
+ this.submitTarget.disabled = false;
285
+
286
+ window.scrollTo({
287
+ top: 0,
288
+ behavior: 'smooth'
289
+ })
290
+ }
291
+ }
292
+
293
+ // Show a spinner on payment submission
294
+ setLoading(isLoading) {
295
+ if (isLoading) {
296
+ // Disable the button and show a spinner
297
+ this.submitTarget.disabled = true
298
+ } else {
299
+ this.loadingTarget.classList.add('hidden')
300
+ this.submitTarget.disabled = false
301
+ this.submitTarget.classList.remove('hidden')
302
+ }
303
+ }
304
+
305
+ get spreeApiHeaders() {
306
+ return {
307
+ 'X-Spree-Order-Token': this.orderTokenValue,
308
+ 'Content-Type': 'application/json'
309
+ }
310
+ }
311
+ }
@@ -0,0 +1,5 @@
1
+ module SpreeStripe
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeStripe.queue
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module SpreeStripe
2
+ class CompleteOrderJob < BaseJob
3
+ def perform(payment_intent_id)
4
+ payment_intent = SpreeStripe::PaymentIntent.find(payment_intent_id)
5
+ SpreeStripe::CompleteOrder.new(payment_intent: payment_intent).call
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module SpreeStripe
2
+ class CreateWebhookEndpointJob < BaseJob
3
+ def perform(payment_method_id)
4
+ Spree::PaymentMethod.find(payment_method_id).create_webhook_endpoint
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ module SpreeStripe
2
+ class RegisterDomainJob < BaseJob
3
+ def perform(model_id, klass_type = 'store')
4
+ @klass_type = klass_type
5
+ model = klass.find(model_id)
6
+ RegisterDomain.new.call(model: model)
7
+ end
8
+
9
+ private
10
+
11
+ def klass
12
+ @klass ||= case @klass_type
13
+ when 'store'
14
+ Spree::Store
15
+ when 'custom_domain'
16
+ Spree::CustomDomain
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,6 @@
1
+ module SpreeStripe
2
+ class Base < Spree::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = 'spree_stripe_'
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ module SpreeStripe
2
+ module CreditCardDecorator
3
+ def self.prepended(base)
4
+ base.store_accessor :private_metadata, :wallet
5
+ end
6
+
7
+ def wallet_type
8
+ wallet&.[]('type')
9
+ end
10
+ end
11
+ end
12
+
13
+ Spree::CreditCard.prepend(SpreeStripe::CreditCardDecorator)
@@ -0,0 +1,18 @@
1
+ module SpreeStripe
2
+ module CustomDomainDecorator
3
+ def self.prepended(base)
4
+ base.after_create :register_stripe_domain
5
+
6
+ base.store_accessor :private_metadata, :stripe_apple_pay_domain_id
7
+ base.store_accessor :private_metadata, :stripe_top_level_domain_id
8
+ end
9
+
10
+ def register_stripe_domain
11
+ return if store.stripe_gateway.blank?
12
+
13
+ SpreeStripe::RegisterDomainJob.perform_later(id, 'custom_domain')
14
+ end
15
+ end
16
+ end
17
+
18
+ Spree::CustomDomain.prepend(SpreeStripe::CustomDomainDecorator)