solidus_stripe 4.4.0 → 5.0.0.rc.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +102 -41
- data/.gitignore +3 -0
- data/.rubocop.yml +94 -4
- data/.yardopts +1 -0
- data/CHANGELOG.md +1 -265
- data/Gemfile +10 -30
- data/LICENSE +2 -2
- data/Procfile.dev +3 -0
- data/README.md +185 -213
- data/Rakefile +7 -6
- data/app/assets/javascripts/spree/backend/solidus_stripe.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_stripe.css +4 -0
- data/app/controllers/solidus_stripe/intents_controller.rb +36 -52
- data/app/controllers/solidus_stripe/webhooks_controller.rb +28 -0
- data/app/models/concerns/solidus_stripe/log_entries.rb +31 -0
- data/app/models/solidus_stripe/customer.rb +21 -0
- data/app/models/solidus_stripe/gateway.rb +229 -0
- data/app/models/solidus_stripe/payment_intent.rb +124 -0
- data/app/models/solidus_stripe/payment_method.rb +106 -0
- data/app/models/solidus_stripe/payment_source.rb +31 -0
- data/app/models/solidus_stripe/slug_entry.rb +20 -0
- data/app/models/solidus_stripe.rb +7 -0
- data/app/subscribers/solidus_stripe/webhook/charge_subscriber.rb +28 -0
- data/app/subscribers/solidus_stripe/webhook/payment_intent_subscriber.rb +112 -0
- data/app/views/spree/admin/payments/source_forms/_stripe.html.erb +29 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/_stripe.html.erb +19 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_card.html.erb +8 -0
- data/app/views/spree/admin/payments/source_forms/existing_payment/stripe/_default.html.erb +7 -0
- data/app/views/spree/admin/payments/source_views/_stripe.html.erb +15 -0
- data/app/views/spree/api/payments/source_views/_stripe.json.jbuilder +8 -0
- data/bin/dev +13 -0
- data/bin/dummy-app +29 -0
- data/bin/rails +38 -3
- data/bin/rails-dummy-app +3 -0
- data/bin/rails-engine +1 -11
- data/bin/rails-new +55 -0
- data/bin/rails-sandbox +1 -14
- data/bin/rspec +10 -0
- data/bin/sandbox +12 -74
- data/bin/setup +1 -0
- data/bin/update-migrations +56 -0
- data/codecov.yml +12 -0
- data/config/locales/en.yml +16 -1
- data/config/routes.rb +5 -11
- data/coverage.rb +42 -0
- data/db/migrate/20230109183332_create_solidus_stripe_payment_sources.rb +10 -0
- data/db/migrate/20230306105520_create_solidus_stripe_payment_intents.rb +14 -0
- data/db/migrate/20230308122414_create_solidus_stripe_slug_entries.rb +12 -0
- data/db/migrate/20230313150008_create_solidus_stripe_customers.rb +15 -0
- data/db/seeds.rb +6 -24
- data/lib/generators/solidus_stripe/install/install_generator.rb +121 -14
- data/lib/generators/solidus_stripe/install/templates/app/assets/stylesheets/spree/frontend/solidus_stripe.css +13 -0
- data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_confirm_controller.js +39 -0
- data/lib/generators/solidus_stripe/install/templates/app/javascript/controllers/solidus_stripe_payment_controller.js +89 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/_stripe.html.erb +21 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_card.html.erb +8 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/existing_payment/stripe/_default.html.erb +7 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/checkouts/payment/_stripe.html.erb +74 -0
- data/lib/generators/solidus_stripe/install/templates/app/views/orders/payment_info/_stripe.html.erb +32 -0
- data/lib/generators/solidus_stripe/install/templates/config/initializers/solidus_stripe.rb +31 -0
- data/lib/solidus_stripe/configuration.rb +24 -3
- data/lib/solidus_stripe/engine.rb +19 -6
- data/lib/solidus_stripe/money_to_stripe_amount_converter.rb +109 -0
- data/lib/solidus_stripe/refunds_synchronizer.rb +98 -0
- data/lib/solidus_stripe/seeds.rb +19 -0
- data/lib/solidus_stripe/testing_support/factories.rb +153 -0
- data/lib/solidus_stripe/version.rb +1 -1
- data/lib/solidus_stripe/webhook/event.rb +91 -0
- data/lib/solidus_stripe.rb +0 -2
- data/solidus_stripe.gemspec +29 -6
- data/spec/lib/solidus_stripe/configuration_spec.rb +21 -0
- data/spec/lib/solidus_stripe/money_to_stripe_amount_converter_spec.rb +133 -0
- data/spec/lib/solidus_stripe/refunds_synchronizer_spec.rb +238 -0
- data/spec/lib/solidus_stripe/seeds_spec.rb +43 -0
- data/spec/lib/solidus_stripe/webhook/event_spec.rb +134 -0
- data/spec/models/concerns/solidus_stripe/log_entries_spec.rb +54 -0
- data/spec/models/solidus_stripe/customer_spec.rb +47 -0
- data/spec/models/solidus_stripe/gateway_spec.rb +281 -0
- data/spec/models/solidus_stripe/payment_intent_spec.rb +78 -0
- data/spec/models/solidus_stripe/payment_method_spec.rb +137 -0
- data/spec/models/solidus_stripe/payment_source_spec.rb +25 -0
- data/spec/requests/solidus_stripe/intents_controller_spec.rb +29 -0
- data/spec/requests/solidus_stripe/webhooks_controller/charge/refunded_spec.rb +31 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/canceled_spec.rb +23 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/payment_failed_spec.rb +23 -0
- data/spec/requests/solidus_stripe/webhooks_controller/payment_intent/succeeded_spec.rb +29 -0
- data/spec/requests/solidus_stripe/webhooks_controller_spec.rb +56 -0
- data/spec/solidus_stripe_spec_helper.rb +10 -0
- data/spec/subscribers/solidus_stripe/webhook/charge_subscriber_spec.rb +33 -0
- data/spec/subscribers/solidus_stripe/webhook/payment_intent_subscriber_spec.rb +297 -0
- data/spec/support/solidus_stripe/backend_test_helper.rb +185 -0
- data/spec/support/solidus_stripe/checkout_test_helper.rb +365 -0
- data/spec/support/solidus_stripe/factories.rb +5 -0
- data/spec/support/solidus_stripe/webhook/data_fixtures.rb +106 -0
- data/spec/support/solidus_stripe/webhook/event_with_context_factory.rb +82 -0
- data/spec/support/solidus_stripe/webhook/request_helper.rb +32 -0
- data/spec/system/backend/solidus_stripe/orders/payments_spec.rb +145 -0
- data/spec/system/frontend/.keep +0 -0
- data/spec/system/frontend/solidus_stripe/checkout_spec.rb +206 -0
- data/tmp/.keep +0 -0
- metadata +196 -75
- data/.rubocop_todo.yml +0 -298
- data/.travis.yml +0 -28
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-cart-page-checkout.js +0 -122
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-elements.js +0 -148
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-init.js +0 -20
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-intents.js +0 -84
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment-request-button-shared.js +0 -160
- data/app/assets/javascripts/spree/frontend/solidus_stripe/stripe-payment.js +0 -16
- data/app/assets/javascripts/spree/frontend/solidus_stripe.js +0 -6
- data/app/controllers/solidus_stripe/payment_request_controller.rb +0 -52
- data/app/controllers/spree/stripe_controller.rb +0 -13
- data/app/decorators/models/spree/order_update_attributes_decorator.rb +0 -39
- data/app/decorators/models/spree/payment_decorator.rb +0 -11
- data/app/decorators/models/spree/refund_decorator.rb +0 -9
- data/app/models/solidus_stripe/address_from_params_service.rb +0 -72
- data/app/models/solidus_stripe/create_intents_payment_service.rb +0 -114
- data/app/models/solidus_stripe/prepare_order_for_payment_service.rb +0 -46
- data/app/models/solidus_stripe/shipping_rates_service.rb +0 -46
- data/app/models/spree/payment_method/stripe_credit_card.rb +0 -230
- data/bin/r +0 -13
- data/bin/sandbox_rails +0 -18
- data/db/migrate/20181010123508_update_stripe_payment_method_type_to_credit_card.rb +0 -21
- data/lib/assets/stylesheets/spree/frontend/solidus_stripe.scss +0 -11
- data/lib/solidus_stripe/testing_support/card_input_helper.rb +0 -34
- data/lib/tasks/solidus_stripe/db/seed.rake +0 -14
- data/lib/views/api/spree/api/payments/source_views/_stripe.json.jbuilder +0 -3
- data/lib/views/backend/spree/admin/log_entries/_stripe.html.erb +0 -28
- data/lib/views/backend/spree/admin/payments/source_forms/_stripe.html.erb +0 -1
- data/lib/views/backend/spree/admin/payments/source_views/_stripe.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/existing_payment/_stripe.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/_stripe.html.erb +0 -8
- data/lib/views/frontend/spree/checkout/payment/v2/_javascript.html.erb +0 -78
- data/lib/views/frontend/spree/checkout/payment/v3/_elements.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/v3/_form_elements.html.erb +0 -40
- data/lib/views/frontend/spree/checkout/payment/v3/_intents.html.erb +0 -1
- data/lib/views/frontend/spree/checkout/payment/v3/_stripe.html.erb +0 -2
- data/lib/views/frontend/spree/orders/_stripe_payment_request_button.html.erb +0 -14
- data/spec/features/stripe_checkout_spec.rb +0 -486
- data/spec/models/solidus_stripe/address_from_params_service_spec.rb +0 -87
- data/spec/models/solidus_stripe/create_intents_payment_service_spec.rb +0 -127
- data/spec/models/solidus_stripe/prepare_order_for_payment_service_spec.rb +0 -65
- data/spec/models/solidus_stripe/shipping_rates_service_spec.rb +0 -54
- data/spec/models/spree/payment_method/stripe_credit_card_spec.rb +0 -350
- data/spec/requests/payment_requests_spec.rb +0 -152
- data/spec/spec_helper.rb +0 -37
- data/spec/support/solidus_address_helper.rb +0 -15
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Helpers to interoperate amounts between Solidus and Stripe.
|
4
|
+
#
|
5
|
+
# Solidus will provide a "fractional" amount, that is specific for each currency
|
6
|
+
# following the configuration defined in the Money gem.
|
7
|
+
#
|
8
|
+
# Stripe uses the "smallest currency unit", (e.g., 100 cents to charge $1.00 or
|
9
|
+
# 100 to charge ¥100, a zero-decimal currency).
|
10
|
+
#
|
11
|
+
# @see https://stripe.com/docs/currencies#zero-decimal
|
12
|
+
#
|
13
|
+
# We need to ensure the fractional amount is considering the same number of decimals.
|
14
|
+
module SolidusStripe::MoneyToStripeAmountConverter
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
extend self
|
17
|
+
|
18
|
+
# @api private
|
19
|
+
ZERO_DECIMAL_CURRENCIES = %w[
|
20
|
+
BIF
|
21
|
+
CLP
|
22
|
+
DJF
|
23
|
+
GNF
|
24
|
+
JPY
|
25
|
+
KMF
|
26
|
+
KRW
|
27
|
+
MGA
|
28
|
+
PYG
|
29
|
+
RWF
|
30
|
+
UGX
|
31
|
+
VND
|
32
|
+
VUV
|
33
|
+
XAF
|
34
|
+
XOF
|
35
|
+
XPF
|
36
|
+
].freeze
|
37
|
+
|
38
|
+
# @api private
|
39
|
+
THREE_DECIMAL_CURRENCIES = %w[
|
40
|
+
BHD
|
41
|
+
JOD
|
42
|
+
KWD
|
43
|
+
OMR
|
44
|
+
TND
|
45
|
+
].freeze
|
46
|
+
|
47
|
+
# @api private
|
48
|
+
#
|
49
|
+
# Special currencies that are represented in cents but should be
|
50
|
+
# divisible by 100, thus making them integer only.
|
51
|
+
DIVISIBLE_BY_100 = %w[
|
52
|
+
HUF
|
53
|
+
TWD
|
54
|
+
UGX
|
55
|
+
].freeze
|
56
|
+
|
57
|
+
def to_stripe_amount(fractional, currency)
|
58
|
+
solidus_subunit_to_unit, stripe_subunit_to_unit = subunit_to_unit(currency)
|
59
|
+
|
60
|
+
if stripe_subunit_to_unit == solidus_subunit_to_unit
|
61
|
+
fractional
|
62
|
+
else
|
63
|
+
(fractional / solidus_subunit_to_unit.to_d) * stripe_subunit_to_unit.to_d
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def to_solidus_amount(fractional, currency)
|
68
|
+
solidus_subunit_to_unit, stripe_subunit_to_unit = subunit_to_unit(currency)
|
69
|
+
|
70
|
+
if stripe_subunit_to_unit == solidus_subunit_to_unit
|
71
|
+
fractional
|
72
|
+
else
|
73
|
+
(fractional / stripe_subunit_to_unit.to_d) * solidus_subunit_to_unit.to_d
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Transforms a decimal amount into a subunit amount
|
78
|
+
#
|
79
|
+
# @param amount [BigDecimal]
|
80
|
+
# @param currency [String]
|
81
|
+
# @return [Integer]
|
82
|
+
def solidus_decimal_to_subunit(amount, currency)
|
83
|
+
Money.from_amount(amount, currency).fractional
|
84
|
+
end
|
85
|
+
|
86
|
+
# Transforms a subunit amount into a decimal amount
|
87
|
+
#
|
88
|
+
# @param amount [Integer]
|
89
|
+
# @param currency [String]
|
90
|
+
# @return [BigDecimal]
|
91
|
+
def solidus_subunit_to_decimal(amount, currency)
|
92
|
+
Money.new(amount, currency).to_d
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def subunit_to_unit(currency)
|
98
|
+
solidus_subunit_to_unit = ::Money::Currency.new(currency).subunit_to_unit
|
99
|
+
stripe_subunit_to_unit =
|
100
|
+
case currency.to_s.upcase
|
101
|
+
when *ZERO_DECIMAL_CURRENCIES then 1
|
102
|
+
when *THREE_DECIMAL_CURRENCIES then 1000
|
103
|
+
when *DIVISIBLE_BY_100 then 100
|
104
|
+
else 100
|
105
|
+
end
|
106
|
+
|
107
|
+
[solidus_subunit_to_unit, stripe_subunit_to_unit]
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "solidus_stripe/money_to_stripe_amount_converter"
|
4
|
+
|
5
|
+
module SolidusStripe
|
6
|
+
# Synchronizes refunds from Stripe to Solidus.
|
7
|
+
#
|
8
|
+
# For our use case, Stripe has two ways to inform us about refunds initiated
|
9
|
+
# on their side:
|
10
|
+
#
|
11
|
+
# 1. The `charge.refunded` webhook event, which is triggered when a refund is
|
12
|
+
# explicitly created.
|
13
|
+
# 2. The `payment_intent.succeeded` webhook event, which is triggered when a
|
14
|
+
# payment intent is captured. If the payment intent is captured for less than
|
15
|
+
# the full amount, a refund is automatically created for the remaining amount.
|
16
|
+
#
|
17
|
+
# In both cases, Stripe doesn't tell us which refund was recently created, so
|
18
|
+
# we need to fetch all the refunds for the payment intent and check if any of
|
19
|
+
# them is missing on Solidus. We're using the `transaction_id` field on
|
20
|
+
# `Spree::Refund` to match refunds against Stripe refunds ids. We could think
|
21
|
+
# about only syncing the single refund not present on Solidus, but we need to
|
22
|
+
# acknowledge concurrent partial refunds.
|
23
|
+
#
|
24
|
+
# The `Spree::RefundReason` with `SolidusStripe::Config.refund_reason_name`
|
25
|
+
# as name is used as created refunds' reason.
|
26
|
+
#
|
27
|
+
# Besides, we need to account for refunds created from Solidus admin panel,
|
28
|
+
# which calls the Stripe API. In this case, we need to avoid syncing the
|
29
|
+
# refund back to Solidus on the subsequent webhook, otherwise we would end up
|
30
|
+
# with duplicate records. We're marking those refunds with a metadata field on
|
31
|
+
# Stripe, so we can filter them out (see {Gateway#credit}).
|
32
|
+
class RefundsSynchronizer
|
33
|
+
include MoneyToStripeAmountConverter
|
34
|
+
|
35
|
+
SKIP_SYNC_METADATA_KEY = :solidus_skip_sync
|
36
|
+
private_constant :SKIP_SYNC_METADATA_KEY
|
37
|
+
|
38
|
+
SKIP_SYNC_METADATA_VALUE = 'true'
|
39
|
+
private_constant :SKIP_SYNC_METADATA_VALUE
|
40
|
+
|
41
|
+
# Metadata used to mark Stripe refunds that shouldn't be synced back to Solidus.
|
42
|
+
# @return [Hash]
|
43
|
+
def self.skip_sync_metadata
|
44
|
+
{ SKIP_SYNC_METADATA_KEY => SKIP_SYNC_METADATA_VALUE }
|
45
|
+
end
|
46
|
+
|
47
|
+
# @param payment_method [SolidusStripe::PaymentMethod]
|
48
|
+
def initialize(payment_method)
|
49
|
+
@payment_method = payment_method
|
50
|
+
end
|
51
|
+
|
52
|
+
# @param stripe_payment_intent_id [String]
|
53
|
+
def call(stripe_payment_intent_id)
|
54
|
+
payment = @payment_method.payments.find_by!(response_code: stripe_payment_intent_id)
|
55
|
+
|
56
|
+
stripe_refunds(stripe_payment_intent_id)
|
57
|
+
.select { stripe_refund_needs_sync?(_1) }
|
58
|
+
.map { create_refund(payment, _1) }
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def stripe_refunds(stripe_payment_intent_id)
|
64
|
+
@payment_method.gateway.request do
|
65
|
+
Stripe::Refund.list(payment_intent: stripe_payment_intent_id).data
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def stripe_refund_needs_sync?(stripe_refund)
|
70
|
+
originated_outside_solidus = stripe_refund.metadata[SKIP_SYNC_METADATA_KEY] != SKIP_SYNC_METADATA_VALUE
|
71
|
+
not_already_synced = Spree::Refund.find_by(transaction_id: stripe_refund.id).nil?
|
72
|
+
|
73
|
+
originated_outside_solidus && not_already_synced
|
74
|
+
end
|
75
|
+
|
76
|
+
def create_refund(payment, stripe_refund)
|
77
|
+
Spree::Refund.create!(
|
78
|
+
payment: payment,
|
79
|
+
amount: refund_decimal_amount(stripe_refund),
|
80
|
+
transaction_id: stripe_refund.id,
|
81
|
+
reason: SolidusStripe::PaymentMethod.refund_reason
|
82
|
+
).tap { log_refund(payment, _1) }
|
83
|
+
end
|
84
|
+
|
85
|
+
def log_refund(payment, refund)
|
86
|
+
SolidusStripe::LogEntries.payment_log(
|
87
|
+
payment,
|
88
|
+
success: true,
|
89
|
+
message: "Payment was refunded after Stripe event (#{refund.money})"
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
def refund_decimal_amount(stripe_refund)
|
94
|
+
to_solidus_amount(stripe_refund.amount, stripe_refund.currency)
|
95
|
+
.then { |amount| solidus_subunit_to_decimal(amount, stripe_refund.currency) }
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module SolidusStripe
|
4
|
+
# Seed data to seamlessly integrate Solidus with Stripe.
|
5
|
+
module Seeds
|
6
|
+
# Default name for the `Spree::RefundReason` used for Stripe refunds.
|
7
|
+
#
|
8
|
+
# Changed via {SolidusStripe::Configuration#refund_reason_name}.
|
9
|
+
#
|
10
|
+
# @return [String]
|
11
|
+
DEFAULT_STRIPE_REFUND_REASON_NAME = "Refund generated from Stripe"
|
12
|
+
|
13
|
+
def self.refund_reasons
|
14
|
+
Spree::RefundReason.find_or_create_by(
|
15
|
+
name: DEFAULT_STRIPE_REFUND_REASON_NAME
|
16
|
+
) { _1.mutable = false }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -1,4 +1,157 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
FactoryBot.define do
|
4
|
+
factory :solidus_stripe_payment_method, class: 'SolidusStripe::PaymentMethod' do
|
5
|
+
type { "SolidusStripe::PaymentMethod" }
|
6
|
+
name { "Stripe Payment Method" }
|
7
|
+
preferences {
|
8
|
+
{
|
9
|
+
api_key: ENV['SOLIDUS_STRIPE_API_KEY'] ||
|
10
|
+
"sk_dummy_#{SecureRandom.hex(24)}",
|
11
|
+
publishable_key: ENV['SOLIDUS_STRIPE_PUBLISHABLE_KEY'] ||
|
12
|
+
"pk_dummy_#{SecureRandom.hex(24)}",
|
13
|
+
webhook_endpoint_signing_secret: ENV['SOLIDUS_STRIPE_WEBHOOK_SIGNING_SECRET'] ||
|
14
|
+
"whsec_dummy_#{SecureRandom.hex(32)}"
|
15
|
+
}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
factory :solidus_stripe_payment_source, class: 'SolidusStripe::PaymentSource' do
|
20
|
+
association :payment_method, factory: :solidus_stripe_payment_method
|
21
|
+
stripe_payment_method_id { "pm_#{SecureRandom.uuid.delete('-')}" }
|
22
|
+
end
|
23
|
+
|
24
|
+
factory :solidus_stripe_payment, parent: :payment do
|
25
|
+
association :payment_method, factory: :solidus_stripe_payment_method
|
26
|
+
amount { order.outstanding_balance }
|
27
|
+
response_code { "pi_#{SecureRandom.uuid.delete('-')}" }
|
28
|
+
state { 'checkout' }
|
29
|
+
|
30
|
+
transient do
|
31
|
+
stripe_payment_method_id { "pm_#{SecureRandom.uuid.delete('-')}" }
|
32
|
+
end
|
33
|
+
|
34
|
+
source {
|
35
|
+
create(
|
36
|
+
:solidus_stripe_payment_source,
|
37
|
+
payment_method: payment_method,
|
38
|
+
stripe_payment_method_id: stripe_payment_method_id
|
39
|
+
)
|
40
|
+
}
|
41
|
+
|
42
|
+
trait :authorized do
|
43
|
+
state { 'pending' }
|
44
|
+
|
45
|
+
after(:create) do |payment, _evaluator|
|
46
|
+
create(
|
47
|
+
:solidus_stripe_payment_log_entry,
|
48
|
+
:authorize,
|
49
|
+
source: payment
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
trait :captured do
|
55
|
+
state { 'completed' }
|
56
|
+
|
57
|
+
after(:create) do |payment, _evaluator|
|
58
|
+
create(
|
59
|
+
:solidus_stripe_payment_log_entry,
|
60
|
+
:autocapture,
|
61
|
+
source: payment
|
62
|
+
)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
factory :solidus_stripe_payment_intent, class: 'SolidusStripe::PaymentIntent' do
|
68
|
+
association :order
|
69
|
+
association :payment_method, factory: :solidus_stripe_payment_method
|
70
|
+
stripe_intent_id { "pm_#{SecureRandom.uuid.delete('-')}" }
|
71
|
+
end
|
72
|
+
|
73
|
+
factory :solidus_stripe_slug_entry, class: 'SolidusStripe::SlugEntry' do
|
74
|
+
association :payment_method, factory: :solidus_stripe_payment_method
|
75
|
+
slug { SecureRandom.hex(16) }
|
76
|
+
end
|
77
|
+
|
78
|
+
factory :solidus_stripe_customer, class: 'SolidusStripe::Customer' do
|
79
|
+
association :payment_method, factory: :solidus_stripe_payment_method
|
80
|
+
association :source, factory: :user
|
81
|
+
stripe_id { "cus_#{SecureRandom.uuid.delete('-')}" }
|
82
|
+
|
83
|
+
trait :guest do
|
84
|
+
association :source, factory: :order, email: 'guest@example.com', user: nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
factory :solidus_stripe_payment_log_entry, class: 'Spree::LogEntry' do
|
89
|
+
transient do
|
90
|
+
success { true }
|
91
|
+
message { nil }
|
92
|
+
response_code { source.response_code }
|
93
|
+
data { nil }
|
94
|
+
end
|
95
|
+
|
96
|
+
source { create(:payment) }
|
97
|
+
|
98
|
+
trait :authorize do
|
99
|
+
message { 'PaymentIntent was confirmed successfully' }
|
100
|
+
end
|
101
|
+
|
102
|
+
trait :capture do
|
103
|
+
message { 'Payment captured successfully' }
|
104
|
+
end
|
105
|
+
|
106
|
+
trait :autocapture do
|
107
|
+
message { 'PaymentIntent was confirmed and captured successfully' }
|
108
|
+
end
|
109
|
+
|
110
|
+
trait :void do
|
111
|
+
message { 'PaymentIntent was canceled successfully' }
|
112
|
+
end
|
113
|
+
|
114
|
+
trait :refund do
|
115
|
+
message { 'PaymentIntent was refunded successfully' }
|
116
|
+
end
|
117
|
+
|
118
|
+
trait :fail do
|
119
|
+
success { false }
|
120
|
+
message { 'PaymentIntent operation failed with a generic error' }
|
121
|
+
end
|
122
|
+
|
123
|
+
details {
|
124
|
+
YAML.safe_dump(
|
125
|
+
SolidusStripe::LogEntries.build_payment_log(
|
126
|
+
success: success,
|
127
|
+
message: message,
|
128
|
+
response_code: response_code,
|
129
|
+
data: data
|
130
|
+
),
|
131
|
+
permitted_classes: Spree::LogEntry.permitted_classes,
|
132
|
+
aliases: Spree::Config.log_entry_allow_aliases
|
133
|
+
)
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
factory :solidus_stripe_order, parent: :order do
|
138
|
+
transient do
|
139
|
+
amount { 10 }
|
140
|
+
payment_method { build(:solidus_stripe_payment_method) }
|
141
|
+
stripe_payment_method_id { "pm_#{SecureRandom.uuid.delete('-')}" }
|
142
|
+
end
|
143
|
+
|
144
|
+
line_items { [build(:line_item, price: amount)] }
|
145
|
+
|
146
|
+
after(:create) do |order, evaluator|
|
147
|
+
build(
|
148
|
+
:solidus_stripe_payment,
|
149
|
+
amount: evaluator.amount,
|
150
|
+
order: order,
|
151
|
+
payment_method: evaluator.payment_method,
|
152
|
+
stripe_payment_method_id: evaluator.stripe_payment_method_id
|
153
|
+
)
|
154
|
+
order.recalculate
|
155
|
+
end
|
156
|
+
end
|
4
157
|
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SolidusStripe
|
4
|
+
module Webhook
|
5
|
+
# Omnes event wrapping a Stripe event for a given payment method.
|
6
|
+
#
|
7
|
+
# All unknown methods are delegated to the wrapped Stripe event.
|
8
|
+
#
|
9
|
+
# @see https://www.rubydoc.info/gems/stripe/Stripe/Event
|
10
|
+
class Event
|
11
|
+
include Omnes::Event
|
12
|
+
|
13
|
+
PREFIX = "stripe."
|
14
|
+
private_constant :PREFIX
|
15
|
+
|
16
|
+
# /!\ This list must be kept in sync with the list of events in the README
|
17
|
+
CORE_EVENTS = Set[*%i[
|
18
|
+
payment_intent.succeeded
|
19
|
+
payment_intent.payment_failed
|
20
|
+
payment_intent.canceled
|
21
|
+
charge.refunded
|
22
|
+
]].freeze
|
23
|
+
private_constant :CORE_EVENTS
|
24
|
+
|
25
|
+
# @api private
|
26
|
+
class << self
|
27
|
+
def from_request(payload:, signature_header:, slug:, tolerance: default_tolerance)
|
28
|
+
payment_method = SolidusStripe::PaymentMethod.with_slug(slug).first!
|
29
|
+
stripe_event = Stripe::Webhook.construct_event(
|
30
|
+
payload,
|
31
|
+
signature_header,
|
32
|
+
payment_method.preferred_webhook_endpoint_signing_secret,
|
33
|
+
tolerance: tolerance
|
34
|
+
)
|
35
|
+
new(stripe_event: stripe_event, payment_method: payment_method)
|
36
|
+
rescue ActiveRecord::RecordNotFound, Stripe::SignatureVerificationError, JSON::ParserError
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def register(user_events:, bus:, core_events: CORE_EVENTS)
|
41
|
+
(core_events + user_events).each do |event|
|
42
|
+
bus.register(:"stripe.#{event}")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def default_tolerance
|
49
|
+
SolidusStripe.configuration.webhook_signature_tolerance
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# @api private
|
54
|
+
attr_reader :omnes_event_name
|
55
|
+
|
56
|
+
# @attr_reader [SolidusStripe::PaymentMethod]
|
57
|
+
attr_reader :payment_method
|
58
|
+
|
59
|
+
# @api private
|
60
|
+
def initialize(stripe_event:, payment_method:)
|
61
|
+
@stripe_event = stripe_event
|
62
|
+
@payment_method = payment_method
|
63
|
+
@omnes_event_name = :"#{PREFIX}#{stripe_event.type}"
|
64
|
+
end
|
65
|
+
|
66
|
+
# Serializable representation of the event.
|
67
|
+
#
|
68
|
+
# Ready to be consumed by async Omnes adapters, like
|
69
|
+
# `Omnes::Subscriber::Adapter::ActiveJob` or
|
70
|
+
# `Omnes::Subscriber::Adapter::Sidekiq`.
|
71
|
+
#
|
72
|
+
# @return [Hash<String, Object>]
|
73
|
+
def payload
|
74
|
+
{
|
75
|
+
"stripe_event" => @stripe_event.as_json,
|
76
|
+
"payment_method_id" => @payment_method.id
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def method_missing(method_name, ...)
|
83
|
+
@stripe_event.send(method_name, ...)
|
84
|
+
end
|
85
|
+
|
86
|
+
def respond_to_missing?(...)
|
87
|
+
@stripe_event.respond_to?(...)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/solidus_stripe.rb
CHANGED
data/solidus_stripe.gemspec
CHANGED
@@ -15,9 +15,9 @@ Gem::Specification.new do |spec|
|
|
15
15
|
|
16
16
|
spec.metadata['homepage_uri'] = spec.homepage
|
17
17
|
spec.metadata['source_code_uri'] = 'https://github.com/solidusio/solidus_stripe'
|
18
|
-
spec.metadata['changelog_uri'] = 'https://github.com/solidusio/solidus_stripe/
|
18
|
+
spec.metadata['changelog_uri'] = 'https://github.com/solidusio/solidus_stripe/releases'
|
19
19
|
|
20
|
-
spec.required_ruby_version = Gem::Requirement.new('>=
|
20
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 3.0')
|
21
21
|
|
22
22
|
# Specify which files should be added to the gem when it is released.
|
23
23
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
@@ -29,10 +29,33 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.executables = files.grep(%r{^exe/}) { |f| File.basename(f) }
|
30
30
|
spec.require_paths = ["lib"]
|
31
31
|
|
32
|
-
spec.add_dependency 'solidus_core', ['>= 2
|
32
|
+
spec.add_dependency 'solidus_core', ['>= 3.2', '< 4']
|
33
33
|
spec.add_dependency 'solidus_support', '~> 0.8'
|
34
|
-
spec.add_dependency 'activemerchant', '>= 1.105'
|
35
|
-
spec.add_dependency 'rexml'
|
36
34
|
|
37
|
-
spec.
|
35
|
+
spec.add_dependency 'stripe', '~> 8.0'
|
36
|
+
|
37
|
+
spec.add_development_dependency 'redcarpet' # used by yard
|
38
|
+
spec.add_development_dependency 'rubocop', '~> 1.0'
|
39
|
+
spec.add_development_dependency 'rubocop-performance', '~> 1.5'
|
40
|
+
spec.add_development_dependency 'rubocop-rails', '~> 2.3'
|
41
|
+
spec.add_development_dependency 'rubocop-rspec', '~> 2.0'
|
42
|
+
spec.add_development_dependency 'yard'
|
43
|
+
|
44
|
+
spec.post_install_message = <<~TXT
|
45
|
+
------------------------------------------------------------------------
|
46
|
+
⚠️ **WARNING: `solidus_stripe` & `solidus_frontend** ⚠️
|
47
|
+
------------------------------------------------------------------------
|
48
|
+
|
49
|
+
If you need support for `solidus_frontend` please add `< 5`
|
50
|
+
as a version requirement in your gemfile:
|
51
|
+
|
52
|
+
`gem 'solidus_stripe', '< 5'`
|
53
|
+
|
54
|
+
or if your tracking the github version please switch to
|
55
|
+
the `v4` branch:
|
56
|
+
|
57
|
+
`gem 'solidus_stripe', github: 'solidusio/solidus_stripe', branch: 'v4'`
|
58
|
+
|
59
|
+
------------------------------------------------------------------------
|
60
|
+
TXT
|
38
61
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "solidus_stripe_spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe SolidusStripe::Configuration do
|
6
|
+
describe "#initialize" do
|
7
|
+
it "defaults webhook events to empty" do
|
8
|
+
expect(described_class.new.webhook_events).to eq([])
|
9
|
+
end
|
10
|
+
|
11
|
+
it "defaults webhook signature tolerance to stripe's default" do
|
12
|
+
expect(described_class.new.webhook_signature_tolerance).to eq(Stripe::Webhook::DEFAULT_TOLERANCE)
|
13
|
+
end
|
14
|
+
|
15
|
+
it "defaults refund reason name to the one in seeds" do
|
16
|
+
expect(
|
17
|
+
described_class.new.refund_reason_name
|
18
|
+
).to eq(SolidusStripe::Seeds::DEFAULT_STRIPE_REFUND_REASON_NAME)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'solidus_stripe_spec_helper'
|
4
|
+
|
5
|
+
# rubocop:disable Style/NumericLiterals
|
6
|
+
RSpec.describe SolidusStripe::MoneyToStripeAmountConverter do
|
7
|
+
describe '.to_stripe_amount' do
|
8
|
+
let(:custom_message) do
|
9
|
+
->(actual, expected, currency) { "Expected #{currency} to be #{expected} but got #{actual}" }
|
10
|
+
end
|
11
|
+
|
12
|
+
context 'with zero decimal currencies' do
|
13
|
+
it 'returns the same unit amount' do
|
14
|
+
(described_class::ZERO_DECIMAL_CURRENCIES - %w[MGA]).each do |currency|
|
15
|
+
actual = described_class.to_stripe_amount(12345, currency).to_i
|
16
|
+
|
17
|
+
expected = 12345
|
18
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context 'with three decimal currencies' do
|
24
|
+
it 'returns the same subunit amount' do
|
25
|
+
described_class::THREE_DECIMAL_CURRENCIES.each do |currency|
|
26
|
+
actual = described_class.to_stripe_amount(12_345, currency).to_i
|
27
|
+
|
28
|
+
expected = 12_345
|
29
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context 'with special cases' do
|
35
|
+
it 'returns the amount divided by 5 (iraimbilanja to ariary) when currency is MGA' do
|
36
|
+
expect(described_class.to_stripe_amount(255, 'MGA').to_i).to eq(51)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'returns the amount as a two decimal currency when currency is HUF' do
|
40
|
+
expect(described_class.to_stripe_amount(1_2345, 'HUF').to_i).to eq(12_345_00)
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'returns the same amount as a zero decimal currency when currency is UGX' do
|
44
|
+
expect(described_class.to_stripe_amount(12_3450_00, 'UGX').to_i).to eq(123_450_00)
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'returns the same amount as a zero decimal currency when currency is TWD' do
|
48
|
+
expect(described_class.to_stripe_amount(1_2345_00, 'TWD').to_i).to eq(12_345_00)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
context 'with default as two decimal currencies' do
|
53
|
+
it 'returns the same subunit amount' do
|
54
|
+
%w[USD EUR ILS MXN TWD].each do |currency|
|
55
|
+
actual = described_class.to_stripe_amount(123_45, currency).to_i
|
56
|
+
|
57
|
+
expected = 123_45
|
58
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe '.to_solidus_amount' do
|
65
|
+
let(:custom_message) do
|
66
|
+
->(actual, expected, currency) { "Expected #{currency} to be #{expected} but got #{actual}" }
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'with zero decimal currencies' do
|
70
|
+
it 'returns the same unit amount' do
|
71
|
+
(described_class::ZERO_DECIMAL_CURRENCIES - %w[MGA]).each do |currency|
|
72
|
+
actual = described_class.to_stripe_amount(12345, currency).to_i
|
73
|
+
|
74
|
+
expected = 12345
|
75
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
context 'with three decimal currencies' do
|
81
|
+
it 'returns the same subunit amount' do
|
82
|
+
described_class::THREE_DECIMAL_CURRENCIES.each do |currency|
|
83
|
+
actual = described_class.to_solidus_amount(12_345, currency).to_i
|
84
|
+
|
85
|
+
expected = 12_345
|
86
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
context 'with special cases' do
|
92
|
+
it 'returns the amount multiplied by 5 (ariary to iraimbilanja) when currency is MGA' do
|
93
|
+
expect(described_class.to_solidus_amount(51, 'MGA').to_i).to eq(255)
|
94
|
+
end
|
95
|
+
|
96
|
+
it 'returns the amount without the last two digits when currency is HUF' do
|
97
|
+
expect(described_class.to_solidus_amount(12_345_00, 'HUF').to_i).to eq(1_2345)
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'returns the amount as a zero decimal currency when currency is UGX' do
|
101
|
+
expect(described_class.to_solidus_amount(123_450_00, 'UGX').to_i).to eq(12_3450_00)
|
102
|
+
end
|
103
|
+
|
104
|
+
it 'returns the amount as a zero decimal currency when currency is TWD' do
|
105
|
+
expect(described_class.to_solidus_amount(12_345_00, 'TWD').to_i).to eq(1_2345_00)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'with default as two decimal currencies' do
|
110
|
+
it 'returns the same subunit amount' do
|
111
|
+
%w[USD EUR ILS MXN TWD].each do |currency|
|
112
|
+
actual = described_class.to_solidus_amount(123_45, currency).to_i
|
113
|
+
|
114
|
+
expected = 123_45
|
115
|
+
expect(actual).to eq(expected), custom_message.call(actual, expected, currency)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
describe '.solidus_decimal_to_subunit' do
|
122
|
+
it 'returns the subunit amount as an integer' do
|
123
|
+
expect(described_class.solidus_decimal_to_subunit(100, 'USD')).to be(100_00)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
describe '.solidus_subunit_to_decimal' do
|
128
|
+
it 'returns to the unit amount as a decimal' do
|
129
|
+
expect(described_class.solidus_subunit_to_decimal(100_00, 'USD')).to eq(BigDecimal("100"))
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
# rubocop:enable Style/NumericLiterals
|