spree_adyen 0.10.0 → 0.11.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efdb74fe9f9d7848da506be961b930c3b2352d6726a1e5fdf81a160ee1688fc6
4
- data.tar.gz: 997de8336c6d59668a1830c576d26778600bf20dcdf79c4307a64b22ee4ab69f
3
+ metadata.gz: 68dde4ded2953b96ce5ba2b1573fc01d88b596025ad17b2e280eaedf472afd32
4
+ data.tar.gz: 1c6becb64dbda8435584ff7cf5b506c10da7cd118918a7ce99b4b94d5c05b966
5
5
  SHA512:
6
- metadata.gz: 9d78966a7463a76cf3dd572d50b09845928be5e6842c30f79cb8139f1c47489cef675c8689ee5b9aaf21a7ec79026d80a081ca8f20e1204ab65f274c5f5f8174
7
- data.tar.gz: 90c1367fa54b80f8194252cfe03e19157d38933a6ed64cf6d18e00467ad43c643cf97b9974626bdf814ac250174e8dec89e96088df70317d03b4f287dc23b765
6
+ metadata.gz: 02db8bc2a625ed1bbe3a96b0b2b976f0551d848f6426bdfb405e331e77dd2ffac1da8576618b1781e0b820b6612dc91f80e70565a54acfa33beecb82fe5bcd01
7
+ data.tar.gz: b534ff3643c997f65c5d7b71d314a47cda463e7cf17067deeef93bcf8473a6a56900bd4daed197399eb34d7ebb925f89ecce025859a644bfd93aea2b3637bb29
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ class PaymentSetupSessions::Adyen < PaymentSetupSession
3
+ delegate :preferred_client_key, to: :payment_method
4
+
5
+ def adyen_id
6
+ external_id
7
+ end
8
+
9
+ def session_data
10
+ external_data&.dig('session_data')
11
+ end
12
+
13
+ def client_key
14
+ preferred_client_key
15
+ end
16
+
17
+ def channel
18
+ external_data&.dig('channel') || Spree::PaymentSessions::Adyen::AVAILABLE_CHANNELS[:web]
19
+ end
20
+
21
+ def return_url
22
+ external_data&.dig('return_url')
23
+ end
24
+
25
+ def successful?
26
+ status == 'completed'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,168 @@
1
+ module SpreeAdyen
2
+ class Gateway < ::Spree::Gateway
3
+ module PaymentSetupSessions
4
+ extend ActiveSupport::Concern
5
+
6
+ def setup_session_supported?
7
+ true
8
+ end
9
+
10
+ def payment_setup_session_class
11
+ Spree::PaymentSetupSessions::Adyen
12
+ end
13
+
14
+ # Creates an Adyen zero-auth tokenization session via the Sessions API
15
+ # and persists a Spree::PaymentSetupSessions::Adyen record.
16
+ #
17
+ # @param customer [Spree::User] the customer to vault the payment method for
18
+ # @param external_data [Hash] additional data (e.g., channel, return_url, currency)
19
+ # @return [Spree::PaymentSetupSessions::Adyen] the created setup session
20
+ def create_payment_setup_session(customer:, external_data: {})
21
+ channel = external_data[:channel] || external_data['channel'] || Spree::PaymentSessions::Adyen::AVAILABLE_CHANNELS[:web]
22
+ return_url = external_data[:return_url] || external_data['return_url'] || default_setup_return_url
23
+ currency = external_data[:currency] || external_data['currency']
24
+
25
+ response = create_adyen_setup_session(customer, channel, return_url, currency)
26
+
27
+ payment_setup_session_class.create!(
28
+ customer: customer,
29
+ payment_method: self,
30
+ status: 'pending',
31
+ external_id: response.params['id'],
32
+ external_data: external_data.to_h.stringify_keys.merge(
33
+ 'session_data' => response.params['sessionData'],
34
+ 'channel' => channel,
35
+ 'return_url' => return_url,
36
+ 'shopper_reference' => "customer_#{customer.id}"
37
+ )
38
+ )
39
+ end
40
+
41
+ # Completes a setup session by checking with Adyen and creating a payment source
42
+ # from the stored payment method. Idempotent — if the source was already created
43
+ # by the AUTHORISATION webhook, returns the session unchanged.
44
+ #
45
+ # @param setup_session [Spree::PaymentSetupSessions::Adyen]
46
+ # @param params [Hash] must include :session_result (or :external_data with :redirect_result)
47
+ def complete_payment_setup_session(setup_session:, params: {})
48
+ session_result = params[:session_result] || params['session_result']
49
+
50
+ if session_result.blank?
51
+ external_data = params[:external_data] || params['external_data'] || {}
52
+ redirect_result = external_data[:redirect_result] || external_data['redirect_result']
53
+
54
+ raise Spree::Core::GatewayError, 'session_result or redirect_result is required' if redirect_result.blank?
55
+
56
+ return complete_setup_session_from_redirect(setup_session, redirect_result)
57
+ end
58
+
59
+ complete_setup_session_from_result(setup_session, session_result)
60
+ end
61
+
62
+ # Creates a zero-auth tokenization session via Adyen's Sessions API.
63
+ #
64
+ # @param customer [Spree::User]
65
+ # @param channel [String] Web, iOS, Android
66
+ # @param return_url [String]
67
+ # @param currency [String, nil] defaults to USD
68
+ # @return [Spree::PaymentResponse]
69
+ def create_adyen_setup_session(customer, channel, return_url, currency = nil)
70
+ payload = SpreeAdyen::PaymentSetupSessions::RequestPayloadPresenter.new(
71
+ customer: customer,
72
+ merchant_account: preferred_merchant_account,
73
+ payment_method: self,
74
+ channel: channel,
75
+ return_url: return_url,
76
+ currency: currency
77
+ ).to_h
78
+
79
+ response = send_request do
80
+ client.checkout.payments_api.sessions(payload, headers: { 'Idempotency-Key' => SecureRandom.uuid })
81
+ end
82
+ response_body = response.response
83
+
84
+ if response.status.to_i == 201
85
+ success(response_body.id, response_body)
86
+ else
87
+ failure(response_body.slice('pspReference', 'message').values.join(' - '))
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def complete_setup_session_from_result(setup_session, session_result)
94
+ response = payment_session_result(setup_session.external_id, session_result)
95
+ raise Spree::Core::GatewayError, response.message.presence || 'Adyen session result failed' unless response.success?
96
+
97
+ status = response.params['status']
98
+ raise Spree::Core::GatewayError, 'Adyen session result missing status' if status.blank?
99
+
100
+ process_setup_session_status(setup_session, status, response.params)
101
+ end
102
+
103
+ def complete_setup_session_from_redirect(setup_session, redirect_result)
104
+ response = send_request do
105
+ client.checkout.payments_api.payments_details({ details: { redirectResult: redirect_result } })
106
+ end
107
+
108
+ raise Spree::Core::GatewayError, "Adyen /payments/details returned #{response.status}" if response.status.to_i != 200
109
+
110
+ result_code = response.response&.dig('resultCode')
111
+ raise Spree::Core::GatewayError, 'Adyen /payments/details response missing resultCode' if result_code.blank?
112
+
113
+ status = case result_code
114
+ when 'Authorised' then 'completed'
115
+ when 'Pending', 'Received' then 'paymentPending'
116
+ when 'Cancelled' then 'canceled'
117
+ when 'Refused', 'Error' then 'refused'
118
+ else result_code
119
+ end
120
+ process_setup_session_status(setup_session, status, response.response || {})
121
+ end
122
+
123
+ def process_setup_session_status(setup_session, status, response_params)
124
+ case status
125
+ when 'completed'
126
+ create_source_from_session_result(setup_session, response_params)
127
+ setup_session.complete if setup_session.can_complete?
128
+ when 'canceled'
129
+ setup_session.cancel if setup_session.can_cancel?
130
+ when 'refused', 'expired'
131
+ setup_session.fail if setup_session.can_fail?
132
+ when 'paymentPending'
133
+ setup_session.process if setup_session.can_process?
134
+ else
135
+ Rails.error.unexpected(
136
+ 'Unexpected Adyen setup session status',
137
+ context: { setup_session_id: setup_session.id, status: status },
138
+ source: 'spree_adyen'
139
+ )
140
+ end
141
+
142
+ setup_session
143
+ end
144
+
145
+ # Creates a payment source from the Adyen Sessions result payload.
146
+ # Card details (last4/expiry) may be missing — they are backfilled by the
147
+ # AUTHORISATION webhook via SpreeAdyen::PaymentSetupSessions::HandleAuthorisation.
148
+ # Idempotent: if the webhook already created the source, this is a no-op.
149
+ def create_source_from_session_result(setup_session, response_params)
150
+ return if setup_session.payment_source.present?
151
+
152
+ source = SpreeAdyen::PaymentSetupSessions::CreateSourceFromResult.new(
153
+ setup_session: setup_session,
154
+ response_params: response_params
155
+ ).call
156
+
157
+ setup_session.update!(payment_source: source) if source.present?
158
+ end
159
+
160
+ def default_setup_return_url
161
+ store = stores.first
162
+ return nil unless store
163
+
164
+ "#{store.storefront_url}/adyen/payment_setup_sessions/redirect"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -1,6 +1,7 @@
1
1
  module SpreeAdyen
2
2
  class Gateway < ::Spree::Gateway
3
3
  include PaymentSessions
4
+ include PaymentSetupSessions
4
5
 
5
6
  CAPTURE_PSP_REFERENCE_METAFIELD_KEY = 'adyen.capture_psp_reference'.freeze
6
7
  CANCELLATION_PSP_REFERENCE_METAFIELD_KEY = 'adyen.cancellation_psp_reference'.freeze
@@ -224,10 +225,17 @@ module SpreeAdyen
224
225
  raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid HMAC signature'
225
226
  end
226
227
 
227
- # Find payment session — scoped to this gateway
228
- payment_session = event.session_id.present? ?
229
- Spree::PaymentSessions::Adyen.find_by(payment_method: self, external_id: event.session_id) : nil
228
+ return nil if event.session_id.blank?
230
229
 
230
+ # Setup sessions (zero-auth tokenization) are processed inline here — they don't
231
+ # fit core's payment-session webhook dispatch shape and there is no order to complete.
232
+ setup_session = Spree::PaymentSetupSessions::Adyen.find_by(payment_method: self, external_id: event.session_id)
233
+ if setup_session
234
+ SpreeAdyen::PaymentSetupSessions::HandleAuthorisation.new(setup_session: setup_session, event: event).call if event.code == 'AUTHORISATION'
235
+ return nil
236
+ end
237
+
238
+ payment_session = Spree::PaymentSessions::Adyen.find_by(payment_method: self, external_id: event.session_id)
231
239
  return nil unless payment_session
232
240
 
233
241
  case event.code
@@ -0,0 +1,81 @@
1
+ module SpreeAdyen
2
+ module PaymentSetupSessions
3
+ # Builds the payload for a zero-auth (tokenization-only) Adyen Sessions API request.
4
+ # Used to vault a payment method without charging the customer — Adyen returns an
5
+ # AUTHORISATION webhook with the stored payment method ID in additionalData.
6
+ class RequestPayloadPresenter
7
+ DEFAULT_PARAMS = {
8
+ recurringProcessingModel: 'UnscheduledCardOnFile',
9
+ shopperInteraction: 'Ecommerce',
10
+ storePaymentMethodMode: 'enabled'
11
+ }.freeze
12
+
13
+ DEFAULT_CURRENCY = 'USD'.freeze
14
+
15
+ def initialize(customer:, merchant_account:, payment_method:, channel:, return_url:, currency: nil)
16
+ @customer = customer
17
+ @merchant_account = merchant_account
18
+ @payment_method = payment_method
19
+ @channel = channel
20
+ @return_url = return_url
21
+ @currency = currency || DEFAULT_CURRENCY
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ metadata: {
27
+ spree_payment_method_id: payment_method.id,
28
+ spree_setup_session: true
29
+ },
30
+ amount: {
31
+ value: 0,
32
+ currency: currency
33
+ },
34
+ returnUrl: return_url,
35
+ reference: reference,
36
+ merchantAccount: merchant_account,
37
+ expiresAt: expires_at
38
+ }.merge!(shopper_details, DEFAULT_PARAMS, channel_params, SpreeAdyen::ApplicationInfoPresenter.new.to_h)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :customer, :merchant_account, :payment_method, :channel, :return_url, :currency
44
+
45
+ # Format: SETUP_<payment_method_id>_<unique_guard>
46
+ # Mirrors the order-tied reference convention used in PaymentSessions::RequestPayloadPresenter,
47
+ # but uses a SETUP prefix because there is no order to anchor against.
48
+ def reference
49
+ ['SETUP', payment_method.id, SecureRandom.hex(6)].join('_')
50
+ end
51
+
52
+ def channel_params
53
+ case channel
54
+ when 'iOS'
55
+ { blockedPaymentMethods: ['googlepay'], channel: 'iOS' }
56
+ when 'Android'
57
+ { blockedPaymentMethods: ['applepay'], channel: 'Android' }
58
+ when 'Web'
59
+ { channel: 'Web' }
60
+ else
61
+ {}
62
+ end
63
+ end
64
+
65
+ def shopper_details
66
+ {
67
+ shopperName: {
68
+ firstName: customer.first_name,
69
+ lastName: customer.last_name
70
+ }.compact,
71
+ shopperEmail: customer.email,
72
+ shopperReference: "customer_#{customer.id}"
73
+ }
74
+ end
75
+
76
+ def expires_at
77
+ SpreeAdyen::Config.payment_session_expiration_in_minutes.minutes.from_now.iso8601
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,71 @@
1
+ module SpreeAdyen
2
+ module PaymentSetupSessions
3
+ # Creates a payment source from an Adyen Sessions result payload (the synchronous
4
+ # complete-session response, not a webhook). Cards may be missing last4/expiry —
5
+ # those are backfilled by the AUTHORISATION webhook handler.
6
+ class CreateSourceFromResult
7
+ def initialize(setup_session:, response_params:)
8
+ @setup_session = setup_session
9
+ @response_params = response_params.to_h.with_indifferent_access
10
+ end
11
+
12
+ def call
13
+ return nil if stored_payment_method_id.blank?
14
+
15
+ if credit_card_brand?
16
+ find_or_create_credit_card
17
+ else
18
+ find_or_create_alternative_source
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :setup_session, :response_params
25
+
26
+ delegate :payment_method, :customer, to: :setup_session
27
+
28
+ def additional_data
29
+ @additional_data ||= response_params.fetch('additionalData', {})
30
+ end
31
+
32
+ def stored_payment_method_id
33
+ additional_data['tokenization.storedPaymentMethodId'] ||
34
+ additional_data['recurring.recurringDetailReference']
35
+ end
36
+
37
+ def payment_method_reference
38
+ @payment_method_reference ||= additional_data['paymentMethod']&.to_sym
39
+ end
40
+
41
+ def credit_card_brand?
42
+ payment_method_reference.present? &&
43
+ payment_method_reference.in?(SpreeAdyen::Config.credit_card_sources)
44
+ end
45
+
46
+ def find_or_create_credit_card
47
+ payment_method.credit_cards.capturable.find_or_create_by(
48
+ gateway_payment_profile_id: stored_payment_method_id
49
+ ) do |cc|
50
+ cc.user = customer
51
+ cc.payment_method = payment_method
52
+ cc.cc_type = SpreeAdyen::Webhooks::CreditCardPresenter::CREDIT_CARD_BRANDS.fetch(
53
+ payment_method_reference.to_s, payment_method_reference.to_s
54
+ )
55
+ end
56
+ end
57
+
58
+ def find_or_create_alternative_source
59
+ source_klass = SpreeAdyen::Webhooks::Actions::CreateSource::SOURCE_KLASS_MAP[payment_method_reference] ||
60
+ SpreeAdyen::PaymentSources::Unknown
61
+
62
+ source_klass.find_or_create_by(
63
+ gateway_payment_profile_id: stored_payment_method_id,
64
+ payment_method: payment_method
65
+ ) do |source|
66
+ source.user = customer
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ module SpreeAdyen
2
+ module PaymentSetupSessions
3
+ # Processes an AUTHORISATION webhook for a zero-auth (tokenization-only) setup session.
4
+ # Creates the payment source from the stored payment method ID and transitions the
5
+ # session. Idempotent — safe to invoke for retries.
6
+ class HandleAuthorisation
7
+ def initialize(setup_session:, event:)
8
+ @setup_session = setup_session
9
+ @event = event
10
+ @gateway = setup_session.payment_method
11
+ @user = setup_session.customer
12
+ end
13
+
14
+ def call
15
+ return setup_session if setup_session.completed?
16
+
17
+ Rails.logger.info("[SpreeAdyen][setup_session=#{setup_session.id}][#{event.psp_reference}]: Processing setup authorisation")
18
+
19
+ if event.success?
20
+ handle_success
21
+ else
22
+ handle_failure
23
+ end
24
+
25
+ setup_session
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :setup_session, :event, :gateway, :user
31
+
32
+ def handle_success
33
+ source = SpreeAdyen::Webhooks::Actions::CreateSource.new(
34
+ event: event,
35
+ payment_method: gateway,
36
+ user: user
37
+ ).call
38
+
39
+ setup_session.update!(payment_source: source) if source.present?
40
+ setup_session.complete if setup_session.can_complete?
41
+ end
42
+
43
+ def handle_failure
44
+ setup_session.fail if setup_session.can_fail?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ FactoryBot.define do
2
+ factory :adyen_payment_setup_session, class: 'Spree::PaymentSetupSessions::Adyen' do
3
+ sequence(:external_id) { |n| "CS_setup_#{n}" }
4
+ status { 'pending' }
5
+ payment_method { create(:adyen_gateway) }
6
+ customer factory: :user
7
+
8
+ external_data do
9
+ {
10
+ 'session_data' => 'a very long session data string',
11
+ 'channel' => 'Web'
12
+ }
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeAdyen
2
- VERSION = '0.10.0'.freeze
2
+ VERSION = '0.11.0'.freeze
3
3
 
4
4
  def gem_version
5
5
  Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_adyen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc., Vendo Sp. z o.o.
@@ -150,10 +150,12 @@ files:
150
150
  - app/jobs/spree_adyen/webhooks/process_cancellation_event_job.rb
151
151
  - app/jobs/spree_adyen/webhooks/process_capture_event_job.rb
152
152
  - app/models/spree/payment_sessions/adyen.rb
153
+ - app/models/spree/payment_setup_sessions/adyen.rb
153
154
  - app/models/spree_adyen/base.rb
154
155
  - app/models/spree_adyen/custom_domain_decorator.rb
155
156
  - app/models/spree_adyen/gateway.rb
156
157
  - app/models/spree_adyen/gateway/payment_sessions.rb
158
+ - app/models/spree_adyen/gateway/payment_setup_sessions.rb
157
159
  - app/models/spree_adyen/order_decorator.rb
158
160
  - app/models/spree_adyen/payment_decorator.rb
159
161
  - app/models/spree_adyen/payment_method_decorator.rb
@@ -225,6 +227,7 @@ files:
225
227
  - app/presenters/spree_adyen/capture_payload_presenter.rb
226
228
  - app/presenters/spree_adyen/checkout_presenter.rb
227
229
  - app/presenters/spree_adyen/payment_sessions/request_payload_presenter.rb
230
+ - app/presenters/spree_adyen/payment_setup_sessions/request_payload_presenter.rb
228
231
  - app/presenters/spree_adyen/payments/request_payload_presenter.rb
229
232
  - app/presenters/spree_adyen/refund_payload_presenter.rb
230
233
  - app/presenters/spree_adyen/webhook_payload_presenter.rb
@@ -234,6 +237,8 @@ files:
234
237
  - app/services/spree_adyen/gateways/configure.rb
235
238
  - app/services/spree_adyen/payment_sessions/find_or_create.rb
236
239
  - app/services/spree_adyen/payment_sessions/process_with_result.rb
240
+ - app/services/spree_adyen/payment_setup_sessions/create_source_from_result.rb
241
+ - app/services/spree_adyen/payment_setup_sessions/handle_authorisation.rb
237
242
  - app/services/spree_adyen/webhooks/actions/create_source.rb
238
243
  - app/services/spree_adyen/webhooks/actions/find_or_create_credit_card.rb
239
244
  - app/services/spree_adyen/webhooks/event.rb
@@ -266,6 +271,7 @@ files:
266
271
  - lib/spree_adyen/factories.rb
267
272
  - lib/spree_adyen/testing_support/factories/gateway_factory.rb
268
273
  - lib/spree_adyen/testing_support/factories/payment_session_factory.rb
274
+ - lib/spree_adyen/testing_support/factories/payment_setup_session_factory.rb
269
275
  - lib/spree_adyen/version.rb
270
276
  - lib/spree_api_v2/spree/api/v2/storefront/adyen/base_controller.rb
271
277
  - lib/spree_api_v2/spree/api/v2/storefront/adyen/payment_sessions_controller.rb
@@ -277,9 +283,9 @@ licenses:
277
283
  - MIT
278
284
  metadata:
279
285
  bug_tracker_uri: https://github.com/spree/spree_adyen/issues
280
- changelog_uri: https://github.com/spree/spree_adyen/releases/tag/v0.10.0
286
+ changelog_uri: https://github.com/spree/spree_adyen/releases/tag/v0.11.0
281
287
  documentation_uri: https://docs.spreecommerce.org/
282
- source_code_uri: https://github.com/spree/spree_adyen/tree/v0.10.0
288
+ source_code_uri: https://github.com/spree/spree_adyen/tree/v0.11.0
283
289
  rdoc_options: []
284
290
  require_paths:
285
291
  - lib