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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +41 -104
- data/Rakefile +7 -16
- data/app/jobs/spree_stripe/base_job.rb +1 -1
- data/app/jobs/spree_stripe/create_webhook_endpoint_job.rb +17 -2
- data/app/models/spree/payment_sessions/stripe.rb +45 -11
- data/app/models/spree_stripe/gateway/connect.rb +327 -0
- data/app/models/spree_stripe/gateway/payment_sessions.rb +234 -88
- data/app/models/spree_stripe/gateway/payment_setup_sessions.rb +15 -9
- data/app/models/spree_stripe/gateway/webhooks.rb +107 -0
- data/app/models/spree_stripe/gateway.rb +197 -244
- data/app/models/spree_stripe/payment_sources/ideal.rb +1 -1
- data/app/models/spree_stripe/payment_sources/przelewy24.rb +1 -1
- data/app/models/spree_stripe/payout_provider.rb +348 -0
- data/app/presenters/spree_stripe/customer_presenter.rb +7 -6
- data/app/presenters/spree_stripe/statement_descriptor_suffix_presenter.rb +11 -10
- data/app/services/spree_stripe/create_gateway_webhooks.rb +57 -33
- data/app/services/spree_stripe/create_payment.rb +46 -31
- data/app/services/spree_stripe/create_source.rb +22 -22
- data/app/services/spree_stripe/update_customer.rb +5 -4
- data/app/subscribers/spree_stripe/customer_updated_subscriber.rb +18 -0
- data/config/initializers/stripe.rb +1 -13
- data/config/locales/en.yml +4 -0
- data/lib/generators/spree_stripe/install/install_generator.rb +12 -12
- data/lib/spree_stripe/engine.rb +7 -38
- data/lib/spree_stripe/factories.rb +1 -3
- data/lib/spree_stripe/testing_support/factories/gateway_factory.rb +10 -9
- data/lib/spree_stripe/testing_support/factories/payment_session_factory.rb +50 -0
- data/lib/spree_stripe.rb +7 -8
- data/lib/tasks/migrate_webhook_keys.rake +128 -0
- metadata +29 -168
- data/app/assets/config/spree_stripe_manifest.js +0 -4
- data/app/assets/images/payment_icons/banktransfer.svg +0 -3
- data/app/assets/images/payment_icons/link.svg +0 -1
- data/app/assets/images/payment_icons/sepadebit.svg +0 -22
- data/app/controllers/spree_stripe/apple_pay_domain_verification_controller.rb +0 -11
- data/app/controllers/spree_stripe/confirm_payments_controller.rb +0 -52
- data/app/controllers/spree_stripe/store_controller_decorator.rb +0 -11
- data/app/controllers/stripe_event/webhook_controller_decorator.rb +0 -21
- data/app/helpers/spree_stripe/base_helper.rb +0 -42
- data/app/helpers/spree_stripe/checkout_helper_decorator.rb +0 -17
- data/app/javascript/spree_stripe/application.js +0 -18
- data/app/javascript/spree_stripe/controllers/stripe_button_controller.js +0 -431
- data/app/javascript/spree_stripe/controllers/stripe_controller.js +0 -234
- data/app/jobs/spree_stripe/attach_customer_to_credit_card_job.rb +0 -18
- data/app/jobs/spree_stripe/complete_order_from_session_job.rb +0 -12
- data/app/jobs/spree_stripe/create_tax_transaction_job.rb +0 -10
- data/app/jobs/spree_stripe/register_domain_job.rb +0 -20
- data/app/jobs/spree_stripe/update_customer_job.rb +0 -10
- data/app/models/spree_stripe/base.rb +0 -6
- data/app/models/spree_stripe/calculators/stripe_tax.rb +0 -96
- data/app/models/spree_stripe/credit_card_decorator.rb +0 -49
- data/app/models/spree_stripe/custom_domain_decorator.rb +0 -20
- data/app/models/spree_stripe/gateway/payment_intents.rb +0 -149
- data/app/models/spree_stripe/gateway_customer_decorator.rb +0 -9
- data/app/models/spree_stripe/order_decorator.rb +0 -9
- data/app/models/spree_stripe/payment_decorator.rb +0 -46
- data/app/models/spree_stripe/payment_method_decorator.rb +0 -18
- data/app/models/spree_stripe/payment_methods_webhook_key.rb +0 -8
- data/app/models/spree_stripe/payment_source_decorator.rb +0 -9
- data/app/models/spree_stripe/shipment_decorator.rb +0 -21
- data/app/models/spree_stripe/store_decorator.rb +0 -26
- data/app/models/spree_stripe/user_decorator.rb +0 -21
- data/app/models/spree_stripe/webhook_key.rb +0 -14
- data/app/presenters/spree_stripe/payment_intent_presenter.rb +0 -101
- data/app/presenters/spree_stripe/tax_presenter.rb +0 -63
- data/app/services/spree_stripe/complete_order.rb +0 -105
- data/app/services/spree_stripe/create_payment_session.rb +0 -18
- data/app/services/spree_stripe/register_domain.rb +0 -22
- data/app/services/spree_stripe/webhook_handlers/base.rb +0 -19
- data/app/services/spree_stripe/webhook_handlers/payment_intent_amount_capturable_updated.rb +0 -13
- data/app/services/spree_stripe/webhook_handlers/payment_intent_payment_failed.rb +0 -16
- data/app/services/spree_stripe/webhook_handlers/payment_intent_succeeded.rb +0 -11
- data/app/services/spree_stripe/webhook_handlers/setup_intent_succeeded.rb +0 -34
- data/app/subscribers/spree_stripe/order_completed_subscriber.rb +0 -14
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_stripe.html.erb +0 -0
- data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_stripe.html.erb +0 -47
- data/app/views/spree/admin/payment_methods/descriptions/_spree_stripe.html.erb +0 -15
- data/app/views/spree/checkout/payment/_spree_stripe.html.erb +0 -66
- data/app/views/spree_stripe/_head.html.erb +0 -2
- data/app/views/spree_stripe/_quick_checkout.html.erb +0 -34
- data/config/i18n-tasks.yml +0 -173
- data/config/importmap.rb +0 -8
- data/config/initializers/spree.rb +0 -9
- data/config/routes.rb +0 -11
- data/db/migrate/20250310152812_setup_spree_stripe_models.rb +0 -24
- data/db/migrate/20260610120000_add_fingerprint_to_spree_credit_cards.rb +0 -52
- data/lib/spree_stripe/configuration.rb +0 -11
- data/lib/spree_stripe/testing_support/factories/after_pay_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/alipay_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/ideal_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/klarna_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/link_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/przelewy24_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/sepa_debit_payment_source_factory.rb +0 -6
- data/lib/spree_stripe/testing_support/factories/stripe_payment_session_factory.rb +0 -33
- data/lib/spree_stripe/testing_support/factories/webhook_key_factory.rb +0 -23
- data/lib/spree_stripe/version.rb +0 -7
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2f387f965ec98112170fb6b256195aa241b444a05ffa58cf949224df588fc0a
|
|
4
|
+
data.tar.gz: 3e2361e3d0f42ce61575c14ecf8455623b2b6d4b4e6f6c20bd9d13956451fbf4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-

|
|
42
|
-
|
|
43
|
-
### Quick payment options on the cart (Apple Pay, Google Pay, Link)
|
|
44
|
-
|
|
45
|
-

|
|
46
|
-
|
|
47
|
-
### Quick payments bypassing checkout 1st step (Apple Pay, Google Pay, Link)
|
|
48
|
-
|
|
49
|
-

|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
```ruby
|
|
17
|
+
gem 'spree_stripe'
|
|
18
|
+
```
|
|
76
19
|
|
|
77
|
-
|
|
20
|
+
Then run `bundle install`. The gateway registers itself — there is nothing to
|
|
21
|
+
generate and no migrations to run.
|
|
78
22
|
|
|
79
|
-
|
|
80
|
-
bundle update
|
|
81
|
-
bundle exec rake test_app
|
|
82
|
-
```
|
|
23
|
+
## Configuration
|
|
83
24
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
33
|
+
Set `STRIPE_SIGNING_SECRET` to verify webhooks forwarded by the Stripe CLI
|
|
34
|
+
during local development.
|
|
93
35
|
|
|
94
|
-
|
|
95
|
-
require 'spree_stripe/factories'
|
|
96
|
-
```
|
|
36
|
+
## Upgrading from spree_stripe 1.x
|
|
97
37
|
|
|
98
|
-
|
|
38
|
+
Signing secrets used to live in their own tables. Move them onto the payment
|
|
39
|
+
method with:
|
|
99
40
|
|
|
100
|
-
```
|
|
101
|
-
bundle exec
|
|
102
|
-
bundle exec gem release
|
|
41
|
+
```bash
|
|
42
|
+
bundle exec rake spree:upgrade:migrate_stripe_webhook_keys
|
|
103
43
|
```
|
|
104
44
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
[instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
|
|
111
|
-
pull request.
|
|
52
|
+
## Testing
|
|
112
53
|
|
|
113
|
-
|
|
54
|
+
The suite runs offline against recorded VCR cassettes:
|
|
114
55
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 '
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
require 'rubygems'
|
|
2
|
+
require 'rake'
|
|
3
|
+
require 'rake/testtask'
|
|
4
4
|
require 'rspec/core/rake_task'
|
|
5
|
-
require 'spree/testing_support/
|
|
5
|
+
require 'spree/testing_support/common_rake'
|
|
6
6
|
|
|
7
7
|
RSpec::Core::RakeTask.new
|
|
8
8
|
|
|
9
|
-
task :
|
|
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
|
|
11
|
+
desc "Generates a dummy app for testing"
|
|
18
12
|
task :test_app do
|
|
19
13
|
ENV['LIB_NAME'] = 'spree_stripe'
|
|
20
|
-
Rake::Task['
|
|
21
|
-
install_storefront: true,
|
|
22
|
-
install_admin: true
|
|
23
|
-
)
|
|
14
|
+
Rake::Task['common:test_app'].invoke
|
|
24
15
|
end
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
module SpreeStripe
|
|
2
2
|
class CreateWebhookEndpointJob < BaseJob
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|