spree_stripe 1.8.1 → 6.0.0.beta2

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 +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +41 -104
  4. data/Rakefile +7 -16
  5. data/app/jobs/spree_stripe/base_job.rb +1 -1
  6. data/app/jobs/spree_stripe/create_webhook_endpoint_job.rb +17 -2
  7. data/app/models/spree/payment_sessions/stripe.rb +45 -11
  8. data/app/models/spree_stripe/gateway/connect.rb +327 -0
  9. data/app/models/spree_stripe/gateway/payment_sessions.rb +234 -88
  10. data/app/models/spree_stripe/gateway/payment_setup_sessions.rb +15 -9
  11. data/app/models/spree_stripe/gateway/webhooks.rb +107 -0
  12. data/app/models/spree_stripe/gateway.rb +197 -244
  13. data/app/models/spree_stripe/payment_sources/ideal.rb +1 -1
  14. data/app/models/spree_stripe/payment_sources/przelewy24.rb +1 -1
  15. data/app/models/spree_stripe/payout_provider.rb +348 -0
  16. data/app/presenters/spree_stripe/customer_presenter.rb +7 -6
  17. data/app/presenters/spree_stripe/statement_descriptor_suffix_presenter.rb +11 -10
  18. data/app/services/spree_stripe/create_gateway_webhooks.rb +57 -33
  19. data/app/services/spree_stripe/create_payment.rb +46 -31
  20. data/app/services/spree_stripe/create_source.rb +22 -22
  21. data/app/services/spree_stripe/update_customer.rb +5 -4
  22. data/app/subscribers/spree_stripe/customer_updated_subscriber.rb +18 -0
  23. data/config/initializers/stripe.rb +1 -13
  24. data/config/locales/en.yml +4 -0
  25. data/lib/generators/spree_stripe/install/install_generator.rb +12 -12
  26. data/lib/spree_stripe/engine.rb +7 -38
  27. data/lib/spree_stripe/factories.rb +1 -3
  28. data/lib/spree_stripe/testing_support/factories/gateway_factory.rb +10 -9
  29. data/lib/spree_stripe/testing_support/factories/payment_session_factory.rb +50 -0
  30. data/lib/spree_stripe.rb +7 -8
  31. data/lib/tasks/migrate_webhook_keys.rake +128 -0
  32. metadata +29 -168
  33. data/app/assets/config/spree_stripe_manifest.js +0 -4
  34. data/app/assets/images/payment_icons/banktransfer.svg +0 -3
  35. data/app/assets/images/payment_icons/link.svg +0 -1
  36. data/app/assets/images/payment_icons/sepadebit.svg +0 -22
  37. data/app/controllers/spree_stripe/apple_pay_domain_verification_controller.rb +0 -11
  38. data/app/controllers/spree_stripe/confirm_payments_controller.rb +0 -52
  39. data/app/controllers/spree_stripe/store_controller_decorator.rb +0 -11
  40. data/app/controllers/stripe_event/webhook_controller_decorator.rb +0 -21
  41. data/app/helpers/spree_stripe/base_helper.rb +0 -42
  42. data/app/helpers/spree_stripe/checkout_helper_decorator.rb +0 -17
  43. data/app/javascript/spree_stripe/application.js +0 -18
  44. data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +0 -431
  45. data/app/javascript/spree_stripe/controllers/stripe_controller.js +0 -234
  46. data/app/jobs/spree_stripe/attach_customer_to_credit_card_job.rb +0 -18
  47. data/app/jobs/spree_stripe/complete_order_from_session_job.rb +0 -12
  48. data/app/jobs/spree_stripe/create_tax_transaction_job.rb +0 -10
  49. data/app/jobs/spree_stripe/register_domain_job.rb +0 -20
  50. data/app/jobs/spree_stripe/update_customer_job.rb +0 -10
  51. data/app/models/spree_stripe/base.rb +0 -6
  52. data/app/models/spree_stripe/calculators/stripe_tax.rb +0 -96
  53. data/app/models/spree_stripe/credit_card_decorator.rb +0 -49
  54. data/app/models/spree_stripe/custom_domain_decorator.rb +0 -20
  55. data/app/models/spree_stripe/gateway/payment_intents.rb +0 -149
  56. data/app/models/spree_stripe/gateway_customer_decorator.rb +0 -9
  57. data/app/models/spree_stripe/order_decorator.rb +0 -9
  58. data/app/models/spree_stripe/payment_decorator.rb +0 -46
  59. data/app/models/spree_stripe/payment_method_decorator.rb +0 -18
  60. data/app/models/spree_stripe/payment_methods_webhook_key.rb +0 -8
  61. data/app/models/spree_stripe/payment_source_decorator.rb +0 -9
  62. data/app/models/spree_stripe/shipment_decorator.rb +0 -21
  63. data/app/models/spree_stripe/store_decorator.rb +0 -26
  64. data/app/models/spree_stripe/user_decorator.rb +0 -21
  65. data/app/models/spree_stripe/webhook_key.rb +0 -14
  66. data/app/presenters/spree_stripe/payment_intent_presenter.rb +0 -101
  67. data/app/presenters/spree_stripe/tax_presenter.rb +0 -63
  68. data/app/services/spree_stripe/complete_order.rb +0 -105
  69. data/app/services/spree_stripe/create_payment_session.rb +0 -18
  70. data/app/services/spree_stripe/register_domain.rb +0 -22
  71. data/app/services/spree_stripe/webhook_handlers/base.rb +0 -19
  72. data/app/services/spree_stripe/webhook_handlers/payment_intent_amount_capturable_updated.rb +0 -13
  73. data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +0 -16
  74. data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +0 -11
  75. data/app/services/spree_stripe/webhook_handlers/setup_intent_succeeded.rb +0 -34
  76. data/app/subscribers/spree_stripe/order_completed_subscriber.rb +0 -14
  77. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_stripe.html.erb +0 -0
  78. data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_stripe.html.erb +0 -47
  79. data/app/views/spree/admin/payment_methods/descriptions/_spree_stripe.html.erb +0 -15
  80. data/app/views/spree/checkout/payment/_spree_stripe.html.erb +0 -66
  81. data/app/views/spree_stripe/_head.html.erb +0 -2
  82. data/app/views/spree_stripe/_quick_checkout.html.erb +0 -34
  83. data/config/i18n-tasks.yml +0 -173
  84. data/config/importmap.rb +0 -8
  85. data/config/initializers/spree.rb +0 -9
  86. data/config/routes.rb +0 -11
  87. data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -24
  88. data/db/migrate/20260610120000_add_fingerprint_to_spree_credit_cards.rb +0 -52
  89. data/lib/spree_stripe/configuration.rb +0 -11
  90. data/lib/spree_stripe/testing_support/factories/after_pay_payment_source_factory.rb +0 -6
  91. data/lib/spree_stripe/testing_support/factories/alipay_payment_source_factory.rb +0 -6
  92. data/lib/spree_stripe/testing_support/factories/ideal_payment_source_factory.rb +0 -6
  93. data/lib/spree_stripe/testing_support/factories/klarna_payment_source_factory.rb +0 -6
  94. data/lib/spree_stripe/testing_support/factories/link_payment_source_factory.rb +0 -6
  95. data/lib/spree_stripe/testing_support/factories/przelewy24_payment_source_factory.rb +0 -6
  96. data/lib/spree_stripe/testing_support/factories/sepa_debit_payment_source_factory.rb +0 -6
  97. data/lib/spree_stripe/testing_support/factories/stripe_payment_session_factory.rb +0 -33
  98. data/lib/spree_stripe/testing_support/factories/webhook_key_factory.rb +0 -23
  99. data/lib/spree_stripe/version.rb +0 -7
  100. data/vendor/javascript/@stripe--stripe-js--dist--pure.esm.js.js +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd7ae7fd51255dec03bb8bd709ceae4e9440dc8a61d64a62086fc91950937af0
4
- data.tar.gz: 7f0c2798878679d21e6337269912e5a5be047c57348255b4536cf2f4de23ed87
3
+ metadata.gz: a2f387f965ec98112170fb6b256195aa241b444a05ffa58cf949224df588fc0a
4
+ data.tar.gz: 3e2361e3d0f42ce61575c14ecf8455623b2b6d4b4e6f6c20bd9d13956451fbf4
5
5
  SHA512:
6
- metadata.gz: edb953569b6fdab13d67ef13128f9edd29e2ce85a5a06f5ae7d1a4a2019dcf8a47aa313452474388ae983e2ccaacbbd8f9003dd8070ba34215fc5464a522fd1f
7
- data.tar.gz: 77c47bb3f2d62f3ef0b6cbc48430dc834d6921c1a495aa293b7f3f8c82f25f17703c3a2cc0c3e7522d59e3008fedd5781bb3e9aeafb0d2183e0cf15d6e43cd8e
6
+ metadata.gz: 21b868b04229b49ac1af09d0deeb1d967cf1e81132d78e1c578dea15e188b7c924f25a9c9fe8fb5377bc0835825b81591dae2f7e8be8d004fbf0fd9e846d406c
7
+ data.tar.gz: d7e6fe6db6ec611c1e5ae928be04f23330455357cfa99b9f9c809b7916a615c676b0c2b3c8f1ad02fcc0134d9469ca66b8ab466d34c413a08ad71b63194c8568
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present, Vendo Sp. z o.o., Vendo Connect Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,126 +1,63 @@
1
- <p align="center">
2
- <a href="https://spreecommerce.org">
3
- <img alt="Spree Commerce x Stripe integration" src="https://vendo-production-res.cloudinary.com/image/upload/w_2000/q_auto/v1742930549/docs/github/Spree_Commerce_open-source_eCommerce_Stripe_payments_integration_-_Github_xlrcn8.jpg">
4
- </a>
5
-
6
1
  # Spree Stripe
7
2
 
8
- This is the official Stripe payment gateway extension for [Spree Commerce](https://spreecommerce.org) [open-source eCommerce platform](https://spreecommerce.org/).
9
-
10
- This Stripe integration is bundled in the [Spree Starter](https://github.com/spree/spree_starter/) for your development convenience.
11
-
12
- Or you could follow the [installation instructions](https://spreecommerce.org/docs/integrations/payments/stripe).
13
-
14
- If you like what you see, consider giving this repo a GitHub star :star:
15
-
16
- Thank you for supporting Spree open-source :heart:
17
-
18
- > [!TIP]
19
- > Looking for a [Stripe Connect integration](#looking-for-a-stripe-connect-integration-for-spree) for Spree? It's available with the [Enterprise Edition](https://spreecommerce.org/spree-commerce-version-comparison-community-edition-vs-enterprise-edition/).
20
-
21
- ## Features
22
-
23
- - Support for quick checkout using Apple Pay, Google Pay, Stripe Link
24
- - Support for 3D Secure and other security standards
25
- - Support for off-session payments
26
- - Support for Storefront API integration (see the API docs [here](https://spreecommerce.org/docs/api-reference/storefront/stripe)).
27
- - Accept payments in over 130 currencies
28
- - Accept Credit Cards, Apple Pay, Google Pay, and more
29
- - Accept SEPA Direct Debit payments
30
- - Accept iDEAL payments
31
- - Accept SOFORT payments
32
- - Accept Bancontact payments
33
- - Accept Alipay payments
34
- - Accept WeChat Pay payments
35
- - Accept Afterpay, Klarna, Affirm, and more
36
-
37
- ## What's new?
38
-
39
- ### Installment (BNPL) payments indicator on PDP (Product Detail Page)
40
-
41
- ![Spree_x_Stripe_-_BNPL_Installment_payments_Product_Card_PDP_Product_Detail_Page](https://vendo-production-res.cloudinary.com/image/upload/w_2000/q_auto/v1742983146/docs/github/Spree_x_Stripe_-_BNPL_Installment_payments_Product_Card_PDP_Product_Detail_Page_amhfkw.jpg)
42
-
43
- ### Quick payment options on the cart (Apple Pay, Google Pay, Link)
44
-
45
- ![Apple_Pay_Google_Pay_Link_-_Quick_payment_options_on_the_cart](https://vendo-production-res.cloudinary.com/image/upload/w_2000/q_auto/v1742930027/docs/github/Spree_x_Stripe_-_Apple_Pay_Google_Pay_Link_-_Quick_payment_options_on_the_cart_aw45x9.jpg)
46
-
47
- ### Quick payments bypassing checkout 1st step (Apple Pay, Google Pay, Link)
48
-
49
- ![Apple_Pay_Google_Pay_Link_-_Quick_payments_bypassing_checkout](https://vendo-production-res.cloudinary.com/image/upload/w_2000/q_auto/v1742930027/docs/github/Spree_x_Stripe_-_Apple_Pay_Google_Pay_Link_-_Quick_payments_bypassing_checkout_on_Cart_n6gbh6.jpg)
3
+ Official [Stripe](https://stripe.com) payment gateway for
4
+ [Spree Commerce](https://spreecommerce.org), built on Spree's payment session
5
+ API.
50
6
 
51
- ### Various payment options on the payment step (cards, BNPL, Apple Pay, Google Pay, Link)
52
-
53
- ![Quick payments bypassing checkout 1st step (Apple Pay, Google_Pay, Link)](https://vendo-production-res.cloudinary.com/image/upload/w_2000/q_auto/v1742930027/docs/github/Spree_x_Stripe_-_Apple_Pay_Google_Pay_Link_-_Checkout_payment_step_rxxnr9.jpg)
7
+ Supports card payments plus Klarna, Affirm, Afterpay, Alipay, iDEAL, Link,
8
+ SEPA Direct Debit, Przelewy24 and bank transfers, along with Apple Pay and
9
+ Google Pay quick checkout. Customers can save payment methods for later use
10
+ through setup sessions.
54
11
 
55
12
  ## Installation
56
13
 
57
- 1. Add this extension to your Gemfile with this line:
58
-
59
- ```ruby
60
- bundle add spree_stripe
61
- ```
62
-
63
- 2. Run the install generator
64
-
65
- ```ruby
66
- bundle exec rails g spree_stripe:install
67
- ```
68
-
69
- 3. Restart your server
70
-
71
- If your server was running, restart it so that it can find the assets properly.
72
-
73
- This Stripe integration is also bundled in the [Spree Starter](https://github.com/spree/spree_starter/) for your development convenience.
14
+ Add the gem to your `Gemfile`:
74
15
 
75
- ## Developing
16
+ ```ruby
17
+ gem 'spree_stripe'
18
+ ```
76
19
 
77
- 1. Create a dummy app
20
+ Then run `bundle install`. The gateway registers itself — there is nothing to
21
+ generate and no migrations to run.
78
22
 
79
- ```bash
80
- bundle update
81
- bundle exec rake test_app
82
- ```
23
+ ## Configuration
83
24
 
84
- 2. Add your new code
85
- 3. Run tests
25
+ Create a Stripe payment method in the admin dashboard and enter your
26
+ publishable and secret keys. Everything else is automatic:
86
27
 
87
- ```bash
88
- bundle exec rspec
89
- ```
28
+ - The webhook endpoint is registered with Stripe on save, and its signing
29
+ secret is stored on the payment method.
30
+ - Your storefront domains are registered with Stripe so Apple Pay and Google
31
+ Pay can offer themselves at checkout.
90
32
 
91
- When testing your applications integration with this extension you may use it's factories.
92
- Simply add this require statement to your spec_helper:
33
+ Set `STRIPE_SIGNING_SECRET` to verify webhooks forwarded by the Stripe CLI
34
+ during local development.
93
35
 
94
- ```ruby
95
- require 'spree_stripe/factories'
96
- ```
36
+ ## Upgrading from spree_stripe 1.x
97
37
 
98
- ## Releasing a new version
38
+ Signing secrets used to live in their own tables. Move them onto the payment
39
+ method with:
99
40
 
100
- ```shell
101
- bundle exec gem bump -p -t
102
- bundle exec gem release
41
+ ```bash
42
+ bundle exec rake spree:upgrade:migrate_stripe_webhook_keys
103
43
  ```
104
44
 
105
- For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
45
+ This runs as part of `rake spree:upgrade`. Until it does, gateways carrying a
46
+ pre-6.0 webhook endpoint will reject incoming webhooks.
106
47
 
107
- ## Contributing
48
+ Applications still using the Rails storefront checkout, the legacy
49
+ `stripe_event` webhook path, or Stripe Tax should complete the payment session
50
+ migration before upgrading — those paths ship only in the 1.x series.
108
51
 
109
- If you'd like to contribute, please take a look at the
110
- [instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
111
- pull request.
52
+ ## Testing
112
53
 
113
- Copyright (c) 2026 [Vendo Connect Inc.](https://getvendo.com), released under [MIT](https://github.com/spree/spree_stripe/blob/main/LICENSE).
54
+ The suite runs offline against recorded VCR cassettes:
114
55
 
115
- ## Looking for a Stripe Connect integration for Spree?
116
-
117
- Spree Commerce [Enterprise Edition](https://spreecommerce.org/spree-commerce-version-comparison-community-edition-vs-enterprise-edition/) comes with a fully automated Stripe Connect integration for a [multi-vendor marketplace use case](https://spreecommerce.org/marketplace-ecommerce/):
118
-
119
- - Automated split payments between marketplace and vendors
120
- - Support for multiple payment methods including cards and digital wallets
121
- - Configurable marketplace fees and commission structures
122
- - Automated payouts to vendors
123
- - Comprehensive transaction reporting
124
- - Built-in fraud prevention tools
56
+ ```bash
57
+ bundle exec rake test_app # once
58
+ bundle exec rspec
59
+ ```
125
60
 
126
- Feel free to [reach out](https://spreecommerce.org/get-started/) to learn more.
61
+ Set `RECORD_VCR=1` with real Stripe test credentials to record new
62
+ interactions. Note that renaming a `:vcr`-tagged example changes its cassette
63
+ path, so it will re-record rather than reuse the old file.
data/Rakefile CHANGED
@@ -1,24 +1,15 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
3
-
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
4
  require 'rspec/core/rake_task'
5
- require 'spree/testing_support/extension_rake'
5
+ require 'spree/testing_support/common_rake'
6
6
 
7
7
  RSpec::Core::RakeTask.new
8
8
 
9
- task :default do
10
- if Dir['spec/dummy'].empty?
11
- Rake::Task[:test_app].invoke
12
- Dir.chdir('../../')
13
- end
14
- Rake::Task[:spec].invoke
15
- end
9
+ task default: :spec
16
10
 
17
- desc 'Generates a dummy app for testing'
11
+ desc "Generates a dummy app for testing"
18
12
  task :test_app do
19
13
  ENV['LIB_NAME'] = 'spree_stripe'
20
- Rake::Task['extension:test_app'].execute(
21
- install_storefront: true,
22
- install_admin: true
23
- )
14
+ Rake::Task['common:test_app'].invoke
24
15
  end
@@ -1,5 +1,5 @@
1
1
  module SpreeStripe
2
2
  class BaseJob < Spree::BaseJob
3
- queue_as SpreeStripe.queue
3
+ queue_as { SpreeStripe.queue }
4
4
  end
5
5
  end
@@ -1,7 +1,22 @@
1
1
  module SpreeStripe
2
2
  class CreateWebhookEndpointJob < BaseJob
3
- def perform(payment_method_id)
4
- Spree::PaymentMethod.find(payment_method_id).create_webhook_endpoint
3
+ # @param connect [Boolean] true registers the connected-accounts endpoint,
4
+ # which a marketplace holds alongside its payment one
5
+ def perform(payment_method_id, connect: false)
6
+ # Both endpoints store their credentials in the one serialised
7
+ # `preferences` column, so concurrent jobs would drop each other's
8
+ # signing secret. Locked on load because `with_lock` refuses a record
9
+ # with unsaved changes, and reading a preference populates its default.
10
+ Spree::PaymentMethod.transaction do
11
+ payment_method = Spree::PaymentMethod.lock.find_by(id: payment_method_id)
12
+ next if payment_method.blank?
13
+
14
+ if connect
15
+ payment_method.create_connect_webhook_endpoint
16
+ else
17
+ payment_method.create_webhook_endpoint
18
+ end
19
+ end
5
20
  end
6
21
  end
7
22
  end
@@ -2,7 +2,6 @@ module Spree
2
2
  class PaymentSessions::Stripe < PaymentSession
3
3
  delegate :api_options, to: :payment_method
4
4
 
5
- # Duck-type interface consumed by the CompleteOrder and CreatePayment services
6
5
  def stripe_id
7
6
  external_id
8
7
  end
@@ -31,23 +30,58 @@ module Spree
31
30
  end
32
31
 
33
32
  def successful?
34
- stripe_payment_intent.status == 'succeeded'
33
+ payment_method.payment_intent_successful?(stripe_payment_intent)
35
34
  end
36
35
 
37
36
  def charge_not_required?
38
37
  payment_method.payment_intent_charge_not_required?(stripe_payment_intent)
39
38
  end
40
39
 
41
- def find_or_create_payment!(metadata = {})
42
- return unless persisted?
43
- return payment if payment.present?
40
+ # Warms everything settlement reads from Stripe — the intent, its charge,
41
+ # and the gateway customer a card source needs — so the webhook path's
42
+ # locked settlement does no Stripe I/O.
43
+ def prepare_for_settlement!
44
+ stripe_charge
45
+ payment_method.fetch_or_create_customer(order: owner) if owner&.customer
46
+ self
47
+ end
48
+
49
+ # Core creates the payment; Stripe only says what instrument it was paid
50
+ # with. The charge and customer are already warmed by
51
+ # +prepare_for_settlement!+, so this does no Stripe I/O inside the lock.
52
+ #
53
+ # @return [Spree::PaymentSource, nil]
54
+ def payment_source_for_settlement
55
+ charge = stripe_charge
56
+
57
+ if charge.present?
58
+ SpreeStripe::CreateSource.new(
59
+ owner: owner,
60
+ stripe_payment_method_details: charge.payment_method_details,
61
+ stripe_payment_method_id: charge.payment_method,
62
+ stripe_billing_details: charge.billing_details,
63
+ gateway: payment_method
64
+ ).call
65
+ elsif charge_not_required?
66
+ # Bank transfers settle with no charge object, so the instrument has
67
+ # to come off the intent instead.
68
+ intent = stripe_payment_intent
69
+
70
+ SpreeStripe::CreateSource.new(
71
+ owner: owner,
72
+ stripe_payment_method_details: intent.payment_method,
73
+ stripe_payment_method_id: intent.payment_method.id,
74
+ stripe_billing_details: nil,
75
+ gateway: payment_method
76
+ ).call
77
+ end
78
+ end
44
79
 
45
- SpreeStripe::CreatePayment.new(
46
- order: order,
47
- payment_intent: self,
48
- gateway: payment_method,
49
- amount: amount
50
- ).call
80
+ # Keeps the charge reference alongside whatever the caller passed.
81
+ def apply_settlement_metadata(payment, metadata)
82
+ super
83
+ charge = stripe_charge
84
+ payment.metadata['stripe_charge_id'] = charge.id if charge.present?
51
85
  end
52
86
  end
53
87
  end
@@ -0,0 +1,327 @@
1
+ module SpreeStripe
2
+ class Gateway < ::Spree::Gateway
3
+ # The marketplace half of the gateway: paying sellers out of the account
4
+ # that charged their customers.
5
+ #
6
+ # This lives on the gateway rather than in settings of its own because it
7
+ # is the same Stripe account either way. A marketplace pays sellers from
8
+ # the money it took, and a transfer signed with a different key than the
9
+ # charge cannot name that charge as its funding source — so a second copy
10
+ # of the credentials could only ever drift out of agreement with the first.
11
+ #
12
+ # It does need a **second webhook endpoint**, though, and that is not a
13
+ # detail. Stripe scopes events by where they originate: the marketplace's
14
+ # own charges on one subscription, everything happening inside a seller's
15
+ # connected account on another, each with its own signing secret. Adding
16
+ # the seller events to the payment endpoint would not widen it — it would
17
+ # switch it over, and the payment webhooks would stop arriving.
18
+ module Connect
19
+ extend ActiveSupport::Concern
20
+
21
+ # `account.updated` — onboarding is finished when Stripe says the account
22
+ # can receive transfers, which may be days after the seller stops typing.
23
+ # `payout.*` — Stripe settling a connected account's balance to its bank,
24
+ # which is what completes a payout here.
25
+ CONNECT_EVENTS = %w[
26
+ account.updated
27
+ payout.paid
28
+ payout.failed
29
+ ].freeze
30
+
31
+ included do
32
+ # As with the payment endpoint's pair: Spree registers the endpoint and
33
+ # stores what Stripe hands back.
34
+ preference :connect_webhook_signing_secret, :password, internal: true
35
+ preference :connect_webhook_endpoint_id, :string, internal: true
36
+
37
+ after_commit :create_connect_webhook_endpoint_async, on: %i[create update]
38
+ end
39
+
40
+ # Verifies and acts on one Connect event.
41
+ #
42
+ # @param raw_body [String]
43
+ # @param headers [Hash]
44
+ # @raise [Spree::PaymentMethod::WebhookSignatureError]
45
+ def handle_payout_webhook(raw_body, headers)
46
+ event = verify_connect_webhook_signature(raw_body, headers)
47
+
48
+ case event.type
49
+ when 'account.updated' then handle_account_updated(event)
50
+ when 'payout.paid' then handle_payout(event, 'paid')
51
+ when 'payout.failed' then handle_payout(event, 'failed')
52
+ end
53
+ end
54
+
55
+ # Gives a seller a Stripe Express account and a link to finish setting it
56
+ # up. Express is the right shape for a marketplace: Stripe collects
57
+ # identity and bank details on its own pages, so the marketplace never
58
+ # handles them and inherits Stripe's checks rather than building its own.
59
+ #
60
+ # The link is minted per call rather than stored — Stripe's onboarding
61
+ # links are single-use and short-lived by design.
62
+ #
63
+ # @param seller [Spree::Seller]
64
+ # @param refresh_url [String] where Stripe sends a seller whose link expired
65
+ # @param return_url [String] where Stripe sends them when they finish
66
+ # @return [String] the onboarding URL
67
+ def create_connect_account_link(seller:, refresh_url:, return_url:)
68
+ # A marketplace usually connects Stripe long before it has any sellers,
69
+ # and the endpoint is registered when the gateway is saved — so by the
70
+ # time the first seller onboards there may be nothing listening, and
71
+ # the `account.updated` that says they can be paid never arrives.
72
+ # Asked for here because this is the moment it starts to matter.
73
+ ensure_connect_webhook_endpoint
74
+
75
+
76
+ account_id = payout_account_for(seller) || create_connect_account(seller).tap do |created|
77
+ seller.set_payout_account_reference(SpreeStripe::PayoutProvider, created)
78
+ end
79
+
80
+ Stripe::AccountLink.create(
81
+ {
82
+ account: account_id,
83
+ refresh_url: refresh_url,
84
+ return_url: return_url,
85
+ type: 'account_onboarding'
86
+ },
87
+ api_options
88
+ ).url
89
+ end
90
+
91
+ # Where Stripe sends events originating in sellers' connected accounts.
92
+ # @return [String, nil]
93
+ def connect_webhook_url
94
+ return nil unless store
95
+
96
+ "#{store.url_or_custom_domain}/api/v3/webhooks/payouts/#{prefixed_id}"
97
+ end
98
+
99
+ def create_connect_webhook_endpoint
100
+ SpreeStripe::CreateGatewayWebhooks.new.call(payment_method: self, connect: true)
101
+ end
102
+
103
+ private
104
+
105
+ def create_connect_account(seller)
106
+ params = connect_account_params(seller)
107
+
108
+ Stripe::Account.create(
109
+ params,
110
+ api_options.merge(idempotency_key: account_idempotency_key(seller, params))
111
+ ).id
112
+ end
113
+
114
+ def connect_account_params(seller)
115
+ country_code = seller_country_code(seller)
116
+
117
+ params = {
118
+ country: country_code,
119
+ email: seller.contact_email,
120
+ business_profile: { name: seller.name },
121
+ # What kind of account this is, stated as the three things that
122
+ # actually differ rather than as a preset: the platform pays Stripe's
123
+ # fees and owns the dispute relationship, which is what makes this a
124
+ # marketplace rather than a referral, and the seller gets Stripe's
125
+ # own hosted dashboard.
126
+ #
127
+ # Deliberately no `type: 'express'` beside it. The two are mutually
128
+ # exclusive — Stripe refuses a request carrying both — and `type` is
129
+ # deprecated in favour of stating the properties. One consequence
130
+ # worth knowing: the account reads back as `type: "none"`, so nothing
131
+ # may branch on that field.
132
+ #
133
+ # `requirement_collection` is left unset, which defaults to `stripe`
134
+ # — Stripe collects what it needs through its hosted onboarding, and
135
+ # `application` is not even allowed alongside an Express dashboard.
136
+ controller: {
137
+ fees: { payer: 'application' },
138
+ losses: { payments: 'application' },
139
+ stripe_dashboard: { type: 'express' }
140
+ },
141
+ # `card_payments` is never exercised — the marketplace charges on its
142
+ # own account and only transfers onwards — but Stripe will not grant
143
+ # `transfers` alone outside the recipient agreement, and refuses
144
+ # that agreement for a seller in the platform's own country. Abroad
145
+ # it would be allowed, and is still wrong: a recipient account
146
+ # cannot receive the cross-border payouts this gem then makes.
147
+ capabilities: { transfers: { requested: true }, card_payments: { requested: true } },
148
+ # Spree decides when a seller is settled, so Stripe must not also be
149
+ # paying their balance out on a schedule of its own — two clocks on
150
+ # one relationship, and the seller's own setting would be the one
151
+ # that did nothing.
152
+ settings: { payouts: { schedule: { interval: 'manual' } } },
153
+ metadata: { spree_seller_id: seller.id }
154
+ }
155
+
156
+ params
157
+ end
158
+
159
+ # Identifies one request, not the seller.
160
+ #
161
+ # Two clicks a second apart must not open two Connect accounts, which is
162
+ # what an idempotency key is for. But Stripe caches a failed response
163
+ # against the key for a day, and refuses a retry whose parameters have
164
+ # changed — so a key that never moves means a seller who hit a bad
165
+ # request stays broken until tomorrow, and sees a confusing complaint
166
+ # about mismatched parameters rather than the real problem.
167
+ #
168
+ # Keying on the parameters themselves gets both: the identical request
169
+ # is deduplicated, while a corrected one — a fixed country, an email the
170
+ # seller has since filled in — is a different request and gets a fresh
171
+ # attempt rather than yesterday's cached failure.
172
+ def account_idempotency_key(seller, params)
173
+ digest = Digest::SHA256.hexdigest(params.to_json)
174
+
175
+ "spree-seller-#{seller.prefixed_id}-#{digest[0, 32]}"
176
+ end
177
+
178
+ # Where the seller trades, which decides what currency and bank details
179
+ # their account can hold. Falls back to the marketplace's own country —
180
+ # Stripe would otherwise assume it anyway, and assuming it silently is
181
+ # how a seller ends up with an account no local bank can receive.
182
+ #
183
+ # No service agreement is sent with it. A seller abroad needs the
184
+ # recipient agreement only under Stripe's Global payouts product;
185
+ # marketplaces on Connect's own cross-border payouts need the standard
186
+ # one, and sending recipient there would wrongly bar the seller from
187
+ # taking card payments. Which product a marketplace is on cannot be read
188
+ # off a country code, so core sends neither and lets Stripe apply the
189
+ # right default.
190
+ def seller_country_code(seller)
191
+ seller.billing_address&.country_code.presence || store.default_country_code
192
+ end
193
+
194
+ # Whether Stripe will let this seller be paid. It can go back to false —
195
+ # a seller whose documents expire stops being payable, and the ledger
196
+ # must stop crediting them rather than promise money nothing can send.
197
+ def handle_account_updated(event)
198
+ account = event.data.object
199
+ seller = seller_for_account(account.id)
200
+ return if seller.nil?
201
+
202
+ became_payable = account.payouts_enabled && seller.payouts_enabled_at.nil?
203
+ seller.update!(payouts_enabled_at: account.payouts_enabled ? (seller.payouts_enabled_at || Time.current) : nil)
204
+
205
+ # Anything they earned while unverified is owed and still pending, and
206
+ # this is the moment it can finally be sent.
207
+ Spree::SellerTransfers::ExecutePendingJob.perform_later(seller.id) if became_payable
208
+ end
209
+
210
+ # Stripe's payout belongs to a connected account rather than to one of
211
+ # our settlements, so the seller is found by the account the event names.
212
+ #
213
+ # Matching the settlement is by Stripe's own id first. Stripe redelivers
214
+ # a webhook on any non-2xx or timeout, and without that match a second
215
+ # delivery would skip the settlement it already completed and land on the
216
+ # next one still owed — marking an unrelated payout paid.
217
+ def handle_payout(event, status)
218
+ object = event.data.object
219
+ seller = seller_for_account(event.account)
220
+ return if seller.nil?
221
+
222
+ payout = find_payout(seller, object)
223
+ return if payout.nil?
224
+
225
+ if status == 'paid'
226
+ # A redelivery of a settlement already completed changes nothing.
227
+ return if payout.completed?
228
+
229
+ Spree.seller_payout_complete_workflow.call(seller_payout: payout, reference: object.id)
230
+ else
231
+ # Answered even on a completed settlement. A bank can return a payout
232
+ # days after it was paid, and the money is back in the seller's
233
+ # balance whether or not our books had moved on — left alone, the
234
+ # ledger reports a seller settled while Stripe still holds their
235
+ # earnings. Failing it releases them for the next sweep to send again.
236
+ payout.fail!
237
+ end
238
+ end
239
+
240
+ # Which seller Stripe is talking about. A uniquely indexed read of the
241
+ # account reference, scoped to this store's own sellers.
242
+ #
243
+ # Filed under this gem's own key rather than the store's configured
244
+ # payout provider: Stripe issued the account, so Stripe is what it
245
+ # belongs to, and a store still settling by hand must still be able to
246
+ # onboard sellers to Connect.
247
+ def seller_for_account(account_id)
248
+ return if account_id.blank?
249
+
250
+ Spree::Seller.with_payout_account(store, SpreeStripe::PayoutProvider, account_id).first
251
+ end
252
+
253
+ # @return [String, nil] the Connect account this seller already holds
254
+ def payout_account_for(seller)
255
+ seller.payout_account_reference(SpreeStripe::PayoutProvider)
256
+ end
257
+
258
+ # By Stripe's own id alone. Spree creates the payout, so it stored that
259
+ # id when it did — an event naming an id we hold no settlement for is
260
+ # about a payout somebody made outside Spree, and guessing which of our
261
+ # settlements it meant would complete the wrong one.
262
+ # By Stripe's id first, then by the one we sent with the request.
263
+ #
264
+ # The reference is normally stored the moment the payout is created, but
265
+ # not when the answer to that call was lost — which is exactly the case
266
+ # that most needs the webhook, since nothing else will tell the operator
267
+ # whether the money moved. Stripe echoes our metadata back, so the id we
268
+ # sent identifies the row when the id it assigned is not on file.
269
+ def find_payout(seller, object)
270
+ by_reference = seller.seller_payouts.find_by(reference: object.id)
271
+ return by_reference if by_reference
272
+
273
+ # A Stripe object raises for a key it does not carry rather than
274
+ # answering nil, and an older payout has no metadata at all.
275
+ return nil unless object.respond_to?(:metadata)
276
+
277
+ payout_id = object.metadata['spree_seller_payout_id']
278
+ return nil if payout_id.blank?
279
+
280
+ seller.seller_payouts.find_by(id: payout_id, reference: nil)
281
+ end
282
+
283
+ def verify_connect_webhook_signature(raw_body, headers)
284
+ signature = headers['HTTP_STRIPE_SIGNATURE']
285
+
286
+ connect_webhook_signing_secrets.each do |secret|
287
+ return Stripe::Webhook.construct_event(raw_body, signature, secret)
288
+ rescue Stripe::SignatureVerificationError
289
+ next
290
+ rescue JSON::ParserError
291
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Malformed webhook payload'
292
+ end
293
+
294
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid webhook signature'
295
+ end
296
+
297
+ # Development only, matching the payment endpoint: the Stripe CLI signs
298
+ # forwarded events with its own secret rather than the endpoint's.
299
+ def connect_webhook_signing_secrets
300
+ secrets = [preferred_connect_webhook_signing_secret]
301
+ secrets << ENV['STRIPE_CONNECT_SIGNING_SECRET'] if Rails.env.development?
302
+ secrets.select(&:present?)
303
+ end
304
+
305
+ # The stored secret is the loop guard, as on the payment endpoint —
306
+ # registration writes it back through `update!`, which re-runs this.
307
+ #
308
+ # Deliberately not gated on the store having sellers. The ordinary setup
309
+ # order is to connect Stripe first and invite sellers afterwards, and
310
+ # nothing about creating a seller saves the gateway — so that guard meant
311
+ # the endpoint was never registered at all, and `account.updated` never
312
+ # arrived to say who could be paid.
313
+ def ensure_connect_webhook_endpoint
314
+ return if preferred_connect_webhook_signing_secret.present?
315
+
316
+ SpreeStripe::CreateWebhookEndpointJob.perform_later(id, connect: true)
317
+ end
318
+
319
+ def create_connect_webhook_endpoint_async
320
+ return if only_webhook_registration_changed?
321
+ return if preferred_connect_webhook_signing_secret.present? && !stripe_secret_key_changed?
322
+
323
+ SpreeStripe::CreateWebhookEndpointJob.perform_later(id, connect: true)
324
+ end
325
+ end
326
+ end
327
+ end