spree_adyen 0.10.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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +9 -0
  3. data/README.md +59 -0
  4. data/Rakefile +26 -0
  5. data/app/assets/config/spree_adyen_manifest.js +5 -0
  6. data/app/controllers/spree_adyen/apple_pay_domain_verification_controller.rb +11 -0
  7. data/app/controllers/spree_adyen/payment_sessions_controller.rb +67 -0
  8. data/app/controllers/spree_adyen/store_controller_decorator.rb +9 -0
  9. data/app/controllers/spree_adyen/webhooks_controller.rb +46 -0
  10. data/app/helpers/spree_adyen/base_helper.rb +18 -0
  11. data/app/javascript/spree_adyen/application.js +17 -0
  12. data/app/javascript/spree_adyen/controllers/checkout_adyen_controller.js +80 -0
  13. data/app/javascript/spree_adyen/controllers/redirect_adyen_controller.js +35 -0
  14. data/app/jobs/spree_adyen/add_allowed_origin_job.rb +24 -0
  15. data/app/jobs/spree_adyen/base_job.rb +5 -0
  16. data/app/jobs/spree_adyen/webhooks/process_authorisation_event_job.rb +10 -0
  17. data/app/jobs/spree_adyen/webhooks/process_cancellation_event_job.rb +10 -0
  18. data/app/jobs/spree_adyen/webhooks/process_capture_event_job.rb +10 -0
  19. data/app/models/spree/payment_sessions/adyen.rb +59 -0
  20. data/app/models/spree_adyen/base.rb +6 -0
  21. data/app/models/spree_adyen/custom_domain_decorator.rb +17 -0
  22. data/app/models/spree_adyen/gateway/payment_sessions.rb +151 -0
  23. data/app/models/spree_adyen/gateway.rb +487 -0
  24. data/app/models/spree_adyen/order_decorator.rb +23 -0
  25. data/app/models/spree_adyen/payment_decorator.rb +66 -0
  26. data/app/models/spree_adyen/payment_method_decorator.rb +15 -0
  27. data/app/models/spree_adyen/payment_session.rb +121 -0
  28. data/app/models/spree_adyen/payment_sources/ach_direct_debit.rb +17 -0
  29. data/app/models/spree_adyen/payment_sources/affirm.rb +17 -0
  30. data/app/models/spree_adyen/payment_sources/afterpay.rb +17 -0
  31. data/app/models/spree_adyen/payment_sources/alipay.rb +19 -0
  32. data/app/models/spree_adyen/payment_sources/alipay_hk.rb +17 -0
  33. data/app/models/spree_adyen/payment_sources/alma.rb +17 -0
  34. data/app/models/spree_adyen/payment_sources/ancv.rb +17 -0
  35. data/app/models/spree_adyen/payment_sources/apple_pay.rb +19 -0
  36. data/app/models/spree_adyen/payment_sources/atome.rb +17 -0
  37. data/app/models/spree_adyen/payment_sources/bacs.rb +17 -0
  38. data/app/models/spree_adyen/payment_sources/bancontact.rb +19 -0
  39. data/app/models/spree_adyen/payment_sources/bank_transfer.rb +17 -0
  40. data/app/models/spree_adyen/payment_sources/base.rb +7 -0
  41. data/app/models/spree_adyen/payment_sources/bcmc.rb +17 -0
  42. data/app/models/spree_adyen/payment_sources/bcmc_mobile.rb +17 -0
  43. data/app/models/spree_adyen/payment_sources/benefit.rb +17 -0
  44. data/app/models/spree_adyen/payment_sources/billie.rb +17 -0
  45. data/app/models/spree_adyen/payment_sources/bizum.rb +17 -0
  46. data/app/models/spree_adyen/payment_sources/blik.rb +15 -0
  47. data/app/models/spree_adyen/payment_sources/boleto.rb +17 -0
  48. data/app/models/spree_adyen/payment_sources/cash_app_afterpay.rb +17 -0
  49. data/app/models/spree_adyen/payment_sources/cashapp.rb +17 -0
  50. data/app/models/spree_adyen/payment_sources/clearpay.rb +17 -0
  51. data/app/models/spree_adyen/payment_sources/dana.rb +17 -0
  52. data/app/models/spree_adyen/payment_sources/doku.rb +19 -0
  53. data/app/models/spree_adyen/payment_sources/duitnow.rb +17 -0
  54. data/app/models/spree_adyen/payment_sources/ebanking_fi.rb +19 -0
  55. data/app/models/spree_adyen/payment_sources/eft_directdebit_ca.rb +19 -0
  56. data/app/models/spree_adyen/payment_sources/eftpos_australia.rb +19 -0
  57. data/app/models/spree_adyen/payment_sources/elo.rb +19 -0
  58. data/app/models/spree_adyen/payment_sources/eps.rb +19 -0
  59. data/app/models/spree_adyen/payment_sources/fastlane.rb +19 -0
  60. data/app/models/spree_adyen/payment_sources/fpx.rb +19 -0
  61. data/app/models/spree_adyen/payment_sources/gcash.rb +19 -0
  62. data/app/models/spree_adyen/payment_sources/gift_cards.rb +19 -0
  63. data/app/models/spree_adyen/payment_sources/giropay.rb +19 -0
  64. data/app/models/spree_adyen/payment_sources/givex.rb +19 -0
  65. data/app/models/spree_adyen/payment_sources/gopay_wallet.rb +19 -0
  66. data/app/models/spree_adyen/payment_sources/grabpay.rb +19 -0
  67. data/app/models/spree_adyen/payment_sources/grabpay_paylater.rb +19 -0
  68. data/app/models/spree_adyen/payment_sources/hipercard.rb +19 -0
  69. data/app/models/spree_adyen/payment_sources/ideal.rb +15 -0
  70. data/app/models/spree_adyen/payment_sources/kakaopay.rb +19 -0
  71. data/app/models/spree_adyen/payment_sources/kcp_naverpay.rb +19 -0
  72. data/app/models/spree_adyen/payment_sources/klarna.rb +32 -0
  73. data/app/models/spree_adyen/payment_sources/oney.rb +13 -0
  74. data/app/models/spree_adyen/payment_sources/online_banking_czech_republic.rb +15 -0
  75. data/app/models/spree_adyen/payment_sources/online_banking_poland.rb +15 -0
  76. data/app/models/spree_adyen/payment_sources/pay_by_bank.rb +15 -0
  77. data/app/models/spree_adyen/payment_sources/paypal.rb +15 -0
  78. data/app/models/spree_adyen/payment_sources/paypo.rb +15 -0
  79. data/app/models/spree_adyen/payment_sources/paysafecard.rb +15 -0
  80. data/app/models/spree_adyen/payment_sources/rate_pay_direct_debit.rb +15 -0
  81. data/app/models/spree_adyen/payment_sources/riverty.rb +15 -0
  82. data/app/models/spree_adyen/payment_sources/samsung_pay.rb +15 -0
  83. data/app/models/spree_adyen/payment_sources/scalapay.rb +13 -0
  84. data/app/models/spree_adyen/payment_sources/sepa_direct_debit.rb +15 -0
  85. data/app/models/spree_adyen/payment_sources/trustly.rb +15 -0
  86. data/app/models/spree_adyen/payment_sources/unknown.rb +19 -0
  87. data/app/models/spree_adyen/payment_sources/wechat_pay.rb +15 -0
  88. data/app/models/spree_adyen/store_decorator.rb +17 -0
  89. data/app/presenters/spree_adyen/address_presenter.rb +21 -0
  90. data/app/presenters/spree_adyen/application_info_presenter.rb +21 -0
  91. data/app/presenters/spree_adyen/cancel_payload_presenter.rb +31 -0
  92. data/app/presenters/spree_adyen/capture_payload_presenter.rb +36 -0
  93. data/app/presenters/spree_adyen/checkout_presenter.rb +51 -0
  94. data/app/presenters/spree_adyen/payment_sessions/request_payload_presenter.rb +140 -0
  95. data/app/presenters/spree_adyen/payments/request_payload_presenter.rb +85 -0
  96. data/app/presenters/spree_adyen/refund_payload_presenter.rb +38 -0
  97. data/app/presenters/spree_adyen/webhook_payload_presenter.rb +28 -0
  98. data/app/presenters/spree_adyen/webhooks/credit_card_presenter.rb +43 -0
  99. data/app/services/spree_adyen/gateways/add_allowed_origin.rb +36 -0
  100. data/app/services/spree_adyen/gateways/configuration.rb +41 -0
  101. data/app/services/spree_adyen/gateways/configure.rb +60 -0
  102. data/app/services/spree_adyen/payment_sessions/find_or_create.rb +51 -0
  103. data/app/services/spree_adyen/payment_sessions/process_with_result.rb +49 -0
  104. data/app/services/spree_adyen/webhooks/actions/create_source.rb +112 -0
  105. data/app/services/spree_adyen/webhooks/actions/find_or_create_credit_card.rb +33 -0
  106. data/app/services/spree_adyen/webhooks/event.rb +104 -0
  107. data/app/services/spree_adyen/webhooks/event_processors/authorisation_event_processor.rb +69 -0
  108. data/app/services/spree_adyen/webhooks/event_processors/cancellation_event_processor.rb +49 -0
  109. data/app/services/spree_adyen/webhooks/event_processors/capture_event_processor.rb +48 -0
  110. data/app/services/spree_adyen/webhooks/handle_event.rb +43 -0
  111. data/app/services/spree_adyen/webhooks/standard_hmac_validator.rb +37 -0
  112. data/app/views/layouts/spree_adyen/default.html.erb +14 -0
  113. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_adyen.html.erb +14 -0
  114. data/app/views/spree/admin/payment_methods/custom_form_fields/_spree_adyen.html.erb +18 -0
  115. data/app/views/spree/admin/payment_methods/descriptions/_spree_adyen.html.erb +12 -0
  116. data/app/views/spree/admin/payments/source_forms/_spree_adyen.html.erb +7 -0
  117. data/app/views/spree/checkout/payment/_spree_adyen.html.erb +40 -0
  118. data/app/views/spree_adyen/_drop_in.html.erb +12 -0
  119. data/app/views/spree_adyen/_head.html.erb +2 -0
  120. data/app/views/spree_adyen/payment_sessions/redirect.html.erb +5 -0
  121. data/config/importmap.rb +7 -0
  122. data/config/initializers/spree.rb +34 -0
  123. data/config/initializers/spree_permitted_attributes.rb +5 -0
  124. data/config/locales/en.yml +11 -0
  125. data/config/routes.rb +35 -0
  126. data/db/migrate/20250630150000_setup_spree_adyen_models.rb +17 -0
  127. data/db/migrate/20250811140113_add_channel_to_adyen_payment_sessions.rb +10 -0
  128. data/db/migrate/20250813152608_add_return_url_to_spree_adyen_payment_sessions.rb +15 -0
  129. data/lib/generators/spree_adyen/install/install_generator.rb +20 -0
  130. data/lib/spree_adyen/configuration.rb +42 -0
  131. data/lib/spree_adyen/engine.rb +79 -0
  132. data/lib/spree_adyen/factories.rb +3 -0
  133. data/lib/spree_adyen/testing_support/factories/gateway_factory.rb +29 -0
  134. data/lib/spree_adyen/testing_support/factories/payment_session_factory.rb +40 -0
  135. data/lib/spree_adyen/version.rb +7 -0
  136. data/lib/spree_adyen.rb +39 -0
  137. data/lib/spree_api_v2/spree/api/v2/storefront/adyen/base_controller.rb +21 -0
  138. data/lib/spree_api_v2/spree/api/v2/storefront/adyen/payment_sessions_controller.rb +72 -0
  139. data/lib/spree_api_v2/spree_adyen/api/v2/storefront/payment_session_serializer.rb +21 -0
  140. data/vendor/javascript/@adyen--adyen-web.js +4 -0
  141. data/vendor/stylesheets/adyen.css +106 -0
  142. metadata +301 -0
@@ -0,0 +1,151 @@
1
+ module SpreeAdyen
2
+ class Gateway < ::Spree::Gateway
3
+ module PaymentSessions
4
+ extend ActiveSupport::Concern
5
+
6
+ def session_required?
7
+ true
8
+ end
9
+
10
+ def payment_session_class
11
+ Spree::PaymentSessions::Adyen
12
+ end
13
+
14
+ # Creates a new Adyen payment session via the Adyen Sessions API
15
+ # and persists a Spree::PaymentSessions::Adyen record.
16
+ #
17
+ # @param order [Spree::Order] the order to create a session for
18
+ # @param amount [BigDecimal, nil] the amount (defaults to order total minus store credits)
19
+ # @param external_data [Hash] additional data (e.g., channel, return_url)
20
+ # @return [Spree::PaymentSessions::Adyen] the created payment session record
21
+ def create_payment_session(order:, amount: nil, external_data: {})
22
+ total = amount.presence || order.total_minus_store_credits
23
+ channel = external_data[:channel] || external_data['channel'] || Spree::PaymentSessions::Adyen::AVAILABLE_CHANNELS[:web]
24
+ return_url = external_data[:return_url] || external_data['return_url'] || default_return_url(order)
25
+
26
+ response = create_adyen_session(total, order, channel, return_url)
27
+
28
+ payment_session_class.create!(
29
+ order: order,
30
+ payment_method: self,
31
+ amount: total,
32
+ currency: order.currency,
33
+ status: 'pending',
34
+ external_id: response.params['id'],
35
+ customer: order.user,
36
+ expires_at: response.params['expiresAt'],
37
+ external_data: {
38
+ 'session_data' => response.params['sessionData'],
39
+ 'channel' => channel,
40
+ 'return_url' => return_url
41
+ }
42
+ )
43
+ end
44
+
45
+ # Updates an existing payment session amount.
46
+ #
47
+ # @param payment_session [Spree::PaymentSessions::Adyen] the session to update
48
+ # @param amount [BigDecimal, nil] new amount
49
+ # @param external_data [Hash] additional data to merge
50
+ def update_payment_session(payment_session:, amount: nil, external_data: {})
51
+ attrs = {}
52
+ attrs[:amount] = amount if amount.present?
53
+
54
+ if external_data.present?
55
+ attrs[:external_data] = (payment_session.external_data || {}).merge(external_data.stringify_keys)
56
+ end
57
+
58
+ payment_session.update!(attrs) if attrs.any?
59
+ end
60
+
61
+ # Completes a payment session by checking the session result with Adyen,
62
+ # creating the Payment record, and transitioning the session accordingly.
63
+ #
64
+ # Does NOT complete the order — that is handled by Carts::Complete
65
+ # (called by the storefront or by the webhook handler).
66
+ #
67
+ # @param payment_session [Spree::PaymentSessions::Adyen] the session to complete
68
+ # @param params [Hash] must include :session_result or external_data with :redirect_result
69
+ # @raise [Spree::Core::GatewayError] if neither session_result nor redirect_result is provided
70
+ def complete_payment_session(payment_session:, params: {})
71
+ session_result = params[:session_result] || params['session_result']
72
+
73
+ if session_result.blank?
74
+ external_data = params[:external_data] || params['external_data'] || {}
75
+ redirect_result = external_data[:redirect_result] || external_data['redirect_result']
76
+
77
+ raise Spree::Core::GatewayError, 'session_result or redirect_result is required' if redirect_result.blank?
78
+
79
+ return complete_payment_session_from_redirect(payment_session, redirect_result)
80
+ end
81
+
82
+ complete_payment_session_from_result(payment_session, session_result)
83
+ end
84
+
85
+ private
86
+
87
+ def complete_payment_session_from_result(payment_session, session_result)
88
+ response = payment_session_result(payment_session.external_id, session_result)
89
+ status = response.params.fetch('status')
90
+ process_payment_session_status(payment_session, status)
91
+ end
92
+
93
+ def complete_payment_session_from_redirect(payment_session, redirect_result)
94
+ response = send_request do
95
+ client.checkout.payments_api.payments_details({ details: { redirectResult: redirect_result } })
96
+ end
97
+
98
+ result_code = response.response&.dig('resultCode')
99
+ status = map_result_code_to_status(result_code)
100
+ process_payment_session_status(payment_session, status)
101
+ end
102
+
103
+ def map_result_code_to_status(result_code)
104
+ case result_code
105
+ when 'Authorised' then 'completed'
106
+ when 'Pending', 'Received' then 'paymentPending'
107
+ when 'Cancelled' then 'canceled'
108
+ when 'Refused', 'Error' then 'refused'
109
+ else result_code
110
+ end
111
+ end
112
+
113
+ def process_payment_session_status(payment_session, status)
114
+ payment_session.order.with_lock do
115
+ payment = payment_session.order.payments.where(
116
+ payment_method: payment_session.payment_method,
117
+ response_code: payment_session.external_id
118
+ ).first_or_initialize
119
+
120
+ payment.update!(amount: payment_session.amount, skip_source_requirement: true)
121
+ payment.started_processing! if payment.checkout?
122
+
123
+ case status
124
+ when 'completed'
125
+ payment_session.complete if payment_session.can_complete?
126
+ payment.confirm!
127
+ when 'canceled'
128
+ payment.void! if payment.can_void?
129
+ payment_session.cancel if payment_session.can_cancel?
130
+ when 'refused', 'expired'
131
+ payment.failure! unless payment.failed?
132
+ payment_session.fail if payment_session.can_fail?
133
+ when 'paymentPending'
134
+ payment_session.process if payment_session.can_process?
135
+ else
136
+ Rails.error.unexpected('Unexpected Adyen payment status', context: { order_id: payment_session.order.id, status: status },
137
+ source: 'spree_adyen')
138
+ end
139
+ end
140
+ end
141
+
142
+ def default_return_url(order)
143
+ if SpreeAdyen::Config[:use_legacy_adyen_payment_sessions]
144
+ Spree::Core::Engine.routes.url_helpers.redirect_adyen_payment_session_url(host: order.store.url_or_custom_domain)
145
+ else
146
+ "#{order.store.storefront_url}/adyen/payment_sessions/redirect"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,487 @@
1
+ module SpreeAdyen
2
+ class Gateway < ::Spree::Gateway
3
+ include PaymentSessions
4
+
5
+ CAPTURE_PSP_REFERENCE_METAFIELD_KEY = 'adyen.capture_psp_reference'.freeze
6
+ CANCELLATION_PSP_REFERENCE_METAFIELD_KEY = 'adyen.cancellation_psp_reference'.freeze
7
+
8
+ #
9
+ # Attributes
10
+ #
11
+ attribute :skip_auto_configuration, :boolean, default: false
12
+ attribute :skip_api_key_validation, :boolean, default: false
13
+
14
+ preference :api_key, :password
15
+ preference :merchant_account, :string
16
+ preference :client_key, :password
17
+ preference :hmac_key, :password
18
+ preference :test_mode, :boolean, default: true
19
+ preference :webhook_id, :string
20
+ preference :live_url_prefix, :string
21
+
22
+ has_one_attached :apple_developer_merchantid_domain_association, service: Spree.private_storage_service_name
23
+
24
+ store_accessor :private_metadata, :previous_hmac_key
25
+ #
26
+ # Validations
27
+ #
28
+ validates :preferred_api_key, presence: true
29
+ validates :preferred_live_url_prefix, presence: true, unless: :preferred_test_mode
30
+ validate :validate_api_key, if: -> { preferred_api_key_changed? }, unless: :skip_api_key_validation
31
+
32
+ #
33
+ # Callbacks
34
+ #
35
+ after_commit :configure, if: :preferred_api_key_previously_changed?, unless: :skip_auto_configuration
36
+
37
+ #
38
+ # Associations
39
+ #
40
+ has_many :payment_sessions, class_name: 'SpreeAdyen::PaymentSession',
41
+ foreign_key: 'payment_method_id',
42
+ dependent: :delete_all,
43
+ inverse_of: :payment_method
44
+
45
+ # @param amount_in_cents [Integer] the amount in cents to capture
46
+ # @param payment_source [Spree::CreditCard | Spree::PaymentSource]
47
+ # @param gateway_options [Hash] this is an instance of Spree::Payment::GatewayOptions.to_hash
48
+ def purchase(amount_in_cents, payment_source, gateway_options = {})
49
+ handle_authorize_or_purchase(amount_in_cents, payment_source, gateway_options)
50
+ end
51
+
52
+ # @param amount_in_cents [Integer] the amount in cents to capture
53
+ # @param payment_source [Spree::CreditCard | Spree::PaymentSource]
54
+ # @param gateway_options [Hash] this is an instance of Spree::Payment::GatewayOptions.to_hash
55
+ def authorize(amount_in_cents, payment_source, gateway_options = {})
56
+ handle_authorize_or_purchase(amount_in_cents, payment_source, gateway_options)
57
+ end
58
+
59
+ def handle_authorize_or_purchase(amount_in_cents, payment_source, gateway_options = {})
60
+ payload = SpreeAdyen::Payments::RequestPayloadPresenter.new(
61
+ source: payment_source,
62
+ amount_in_cents: amount_in_cents,
63
+ manual_capture: !auto_capture?,
64
+ gateway_options: gateway_options
65
+ ).to_h
66
+
67
+ response = send_request do
68
+ client.checkout.payments_api.payments(payload, headers: { 'Idempotency-Key' => SecureRandom.uuid })
69
+ end
70
+ response_body = response.response
71
+
72
+ if response.status.to_i == 200
73
+ success(response_body.pspReference, response_body)
74
+ else
75
+ failure(response_body.slice('pspReference', 'message').values.join(' - '))
76
+ end
77
+ end
78
+
79
+ def cancel(id, payment)
80
+ transaction_id = id
81
+ payment ||= Spree::Payment.find_by(response_code: id)
82
+ if payment.completed?
83
+ amount = payment.credit_allowed
84
+ return success(transaction_id, {}) if amount.zero?
85
+ # Don't create a refund if the payment is for a shipment, we will create a refund for the whole shipping cost instead
86
+ return success(transaction_id, {}) if payment.respond_to?(:for_shipment?) && payment.for_shipment?
87
+
88
+ refund = payment.refunds.create!(
89
+ amount: amount,
90
+ reason: Spree::RefundReason.order_canceled_reason,
91
+ refunder_id: payment.order.canceler_id
92
+ )
93
+
94
+ # Spree::Refund#response has the response from the `credit` action
95
+ # For the authorization ID we need to use the payment.response_code
96
+ # Otherwise we'll overwrite the payment authorization with the refund ID
97
+ success(transaction_id, refund.response.params)
98
+ else
99
+ payment.void!
100
+ success(transaction_id, {})
101
+ end
102
+ end
103
+
104
+ def credit(amount_in_cents, _source, payment_id, gateway_options = {})
105
+ refund = gateway_options[:originator]
106
+ payment = refund.present? ? refund.payment : Spree::Payment.find_by(response_code: payment_id)
107
+
108
+ return failure("#{payment_id} - Payment not found") unless payment
109
+
110
+ payload = SpreeAdyen::RefundPayloadPresenter.new(
111
+ payment: payment,
112
+ amount_in_cents: amount_in_cents,
113
+ payment_method: self,
114
+ currency: payment.currency,
115
+ refund: refund
116
+ ).to_h
117
+
118
+ response = send_request do
119
+ client.checkout.modifications_api.refund_captured_payment(payload, payment.transaction_id, headers: { 'Idempotency-Key' => SecureRandom.uuid })
120
+ end
121
+
122
+ if response.status.to_i == 201
123
+ success(response.response['pspReference'], response)
124
+ else
125
+ failure(response.response.slice('pspReference', 'message').values.join(' - '))
126
+ end
127
+ end
128
+
129
+ def request_capture(amount_in_cents, response_code, _gateway_options = {})
130
+ payment = Spree::Payment.find_by(response_code: response_code)
131
+
132
+ return failure("#{response_code} - Payment not found") if payment.blank?
133
+ return failure("#{response_code} - Payment is already captured") if payment.completed?
134
+
135
+ payload = SpreeAdyen::CapturePayloadPresenter.new(
136
+ amount_in_cents: amount_in_cents,
137
+ payment: payment,
138
+ payment_method: self
139
+ ).to_h
140
+
141
+ response = send_request do
142
+ client.checkout.modifications_api.capture_authorised_payment(
143
+ payload,
144
+ payment.response_code,
145
+ headers: { 'Idempotency-Key' => SecureRandom.uuid }
146
+ )
147
+ end
148
+
149
+ if response.status.to_i == 201
150
+ success(response.response['paymentPspReference'], response)
151
+ else
152
+ failure(response.response.slice('paymentPspReference', 'message').values.join(' - '))
153
+ end
154
+ end
155
+
156
+ # This only checks if the capture was successful by checking the presence of the capture PSP reference
157
+ # The actual capture is requested in #request_capture and handled in the SpreeAdyen::Webhooks::EventProcessors::CaptureEventProcessor
158
+ def capture(amount_in_cents, response_code, _gateway_options = {})
159
+ payment = Spree::Payment.find_by(response_code: response_code)
160
+
161
+ return failure("#{response_code} - Payment not found") if payment.blank?
162
+ return failure("#{response_code} - Payment is already captured") if payment.completed?
163
+ return failure("#{response_code} - Capture PSP reference not found") unless payment.has_metafield?(CAPTURE_PSP_REFERENCE_METAFIELD_KEY)
164
+
165
+ success(payment.response_code, {})
166
+ end
167
+
168
+ def request_void(response_code, _source, _gateway_options)
169
+ payment = Spree::Payment.find_by(response_code: response_code)
170
+
171
+ return failure("#{response_code} - Payment not found") if payment.blank?
172
+ return failure("#{response_code} - Payment is already void") if payment.void?
173
+
174
+ payload = SpreeAdyen::CancelPayloadPresenter.new(
175
+ payment: payment,
176
+ payment_method: self
177
+ ).to_h
178
+
179
+ response = send_request do
180
+ client.checkout.modifications_api.cancel_authorised_payment_by_psp_reference(
181
+ payload,
182
+ payment.response_code,
183
+ headers: { 'Idempotency-Key' => SecureRandom.uuid }
184
+ )
185
+ end
186
+
187
+ if response.status.to_i == 201
188
+ success(response.response['paymentPspReference'], response)
189
+ else
190
+ failure(response.response.slice('paymentPspReference', 'message').values.join(' - '))
191
+ end
192
+ end
193
+
194
+ # This only checks if the void was successful by checking the presence of the cancellation PSP reference
195
+ # The actual void is requested in #request_void and handled in the SpreeAdyen::Webhooks::EventProcessors::CancellationEventProcessor
196
+ def void(response_code, _source, _gateway_options)
197
+ payment = Spree::Payment.find_by(response_code: response_code)
198
+
199
+ return failure("#{response_code} - Payment not found") if payment.blank?
200
+ return failure("#{response_code} - Payment is already void") if payment.void?
201
+ return failure("#{response_code} - Cancellation PSP reference not found") unless payment.has_metafield?(CANCELLATION_PSP_REFERENCE_METAFIELD_KEY)
202
+
203
+ success(payment.response_code, {})
204
+ end
205
+
206
+ def webhook_url
207
+ store = stores.first
208
+ return nil unless store
209
+
210
+ if SpreeAdyen::Config[:use_legacy_webhook_handlers]
211
+ "#{store.formatted_url}/adyen/webhooks"
212
+ else
213
+ "#{store.formatted_url}/api/v3/webhooks/payments/#{prefixed_id}"
214
+ end
215
+ end
216
+
217
+ def parse_webhook_event(raw_body, headers)
218
+ payload = JSON.parse(raw_body).with_indifferent_access
219
+ event = SpreeAdyen::Webhooks::Event.new(event_data: payload)
220
+
221
+ # Verify HMAC signature
222
+ webhook_request_item = payload.dig('notificationItems', 0, 'NotificationRequestItem') || {}
223
+ unless valid_hmac?(webhook_request_item)
224
+ raise Spree::PaymentMethod::WebhookSignatureError, 'Invalid HMAC signature'
225
+ end
226
+
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
230
+
231
+ return nil unless payment_session
232
+
233
+ case event.code
234
+ when 'AUTHORISATION'
235
+ action = event.success? ? :authorized : :failed
236
+ { action: action, payment_session: payment_session, metadata: { adyen_event: event } }
237
+ when 'CAPTURE'
238
+ { action: :captured, payment_session: payment_session, metadata: { adyen_event: event } }
239
+ when 'CANCELLATION'
240
+ { action: :canceled, payment_session: payment_session, metadata: { adyen_event: event } }
241
+ else
242
+ nil
243
+ end
244
+ end
245
+
246
+ def provider_class
247
+ self.class
248
+ end
249
+
250
+ def environment
251
+ if preferred_test_mode
252
+ :test
253
+ else
254
+ :live
255
+ end
256
+ end
257
+
258
+ def create_profile(payment); end
259
+
260
+ def payment_session_result(payment_session_id, session_result)
261
+ response = send_request do
262
+ client.checkout.payments_api.get_result_of_payment_session(payment_session_id, query_params: { sessionResult: session_result })
263
+ end
264
+ response_body = response.response
265
+
266
+ if response.status.to_i == 200
267
+ success(response_body.id, response_body)
268
+ else
269
+ failure(response_body.slice('pspReference', 'message').values.join(' - '))
270
+ end
271
+ end
272
+
273
+ # Creates an Adyen session via the Adyen Sessions API.
274
+ # Used internally by the v3 PaymentSessions module and by the legacy SpreeAdyen::PaymentSession model.
275
+ #
276
+ # @param amount [BigDecimal] the amount
277
+ # @param order [Spree::Order] the order to create a session for
278
+ # @param channel [String] the channel (Web, iOS, Android)
279
+ # @param return_url [String] the return URL after redirect flow
280
+ # @return [Spree::PaymentResponse] the response from the session creation
281
+ def create_adyen_session(amount, order, channel, return_url)
282
+ payload = SpreeAdyen::PaymentSessions::RequestPayloadPresenter.new(
283
+ order: order,
284
+ amount: amount,
285
+ user: order.user,
286
+ merchant_account: preferred_merchant_account,
287
+ payment_method: self,
288
+ channel: channel,
289
+ return_url: return_url
290
+ ).to_h
291
+
292
+ response = send_request do
293
+ client.checkout.payments_api.sessions(payload, headers: { 'Idempotency-Key' => SecureRandom.uuid })
294
+ end
295
+ response_body = response.response
296
+
297
+ if response.status.to_i == 201
298
+ success(response_body.id, response_body)
299
+ else
300
+ failure(response_body.slice('pspReference', 'message').values.join(' - '))
301
+ end
302
+ end
303
+
304
+ # @return [Boolean] whether payment profiles are supported
305
+ # this is used by spree to determine whenever payment source must be passed to gateway methods
306
+ def payment_profiles_supported?
307
+ true
308
+ end
309
+
310
+ def default_name
311
+ 'Adyen'
312
+ end
313
+
314
+ def method_type
315
+ 'spree_adyen'
316
+ end
317
+
318
+ def payment_icon_name
319
+ 'adyen'
320
+ end
321
+
322
+ def description_partial_name
323
+ 'spree_adyen'
324
+ end
325
+
326
+ def configuration_guide_partial_name
327
+ 'spree_adyen'
328
+ end
329
+
330
+ def custom_form_fields_partial_name
331
+ 'spree_adyen'
332
+ end
333
+
334
+ def gateway_dashboard_payment_url(payment)
335
+ return if payment.transaction_id.blank?
336
+
337
+ "https://ca-#{environment}.adyen.com/ca/ca/accounts/showTx.shtml?pspReference=#{payment.transaction_id}&txType=Payment"
338
+ end
339
+
340
+ def reusable_sources(order)
341
+ if order.completed?
342
+ sources_by_order order
343
+ elsif order.user.present?
344
+ credit_cards.where(user_id: order.user_id)
345
+ else
346
+ []
347
+ end
348
+ end
349
+
350
+ def get_api_credential_details
351
+ response = client.management.my_api_credential_api.get_api_credential_details
352
+
353
+ if response.status.to_i == 200
354
+ success(response.response.id, response.response)
355
+ else
356
+ failure(response.response.message)
357
+ end
358
+ rescue Adyen::AuthenticationError, Adyen::PermissionError
359
+ raise
360
+ rescue Adyen::AdyenError => e
361
+ failure(parse_adyen_error_response(e)['message'])
362
+ end
363
+
364
+ def add_allowed_origin(domain)
365
+ response = client.management.my_api_credential_api.add_allowed_origin({ domain: domain })
366
+
367
+ if response.status.to_i == 200
368
+ success(response.response.id, response.response)
369
+ else
370
+ failure(response.response)
371
+ end
372
+ rescue Adyen::AdyenError => e
373
+ failure(parse_adyen_error_response(e))
374
+ end
375
+
376
+ def set_up_webhook(url)
377
+ payload = SpreeAdyen::WebhookPayloadPresenter.new(url).to_h
378
+ response = client.management.webhooks_merchant_level_api.set_up_webhook(payload, preferred_merchant_account)
379
+
380
+ if response.status.to_i == 200
381
+ success(response.response.id, response.response)
382
+ else
383
+ failure(response.response)
384
+ end
385
+ rescue Adyen::AdyenError => e
386
+ failure(parse_adyen_error_response(e)['message'])
387
+ end
388
+
389
+ def test_webhook
390
+ response = client.management.webhooks_merchant_level_api.test_webhook({ types: ['AUTHORISATION'] }, preferred_merchant_account,
391
+ preferred_webhook_id)
392
+
393
+ if response.status.to_i == 200 && response.response.dig('data', 0, 'status') == 'success'
394
+ success(nil, response.response)
395
+ else
396
+ failure(response.response)
397
+ end
398
+ rescue Adyen::AdyenError => e
399
+ failure(parse_adyen_error_response(e)['message'])
400
+ end
401
+
402
+ def generate_hmac_key
403
+ response = client.management.webhooks_merchant_level_api.generate_hmac_key(preferred_merchant_account, preferred_webhook_id)
404
+
405
+ if response.status.to_i == 200
406
+ success(response.response.hmacKey, response.response)
407
+ else
408
+ failure(response.response)
409
+ end
410
+ rescue Adyen::AdyenError => e
411
+ failure(parse_adyen_error_response(e)['message'])
412
+ end
413
+
414
+ def generate_client_key
415
+ response = client.management.my_api_credential_api.generate_client_key
416
+
417
+ if response.status.to_i == 200
418
+ success(response.response.clientKey, response.response)
419
+ else
420
+ failure(response.response.message)
421
+ end
422
+ rescue Adyen::AdyenError => e
423
+ failure(parse_adyen_error_response(e)['message'])
424
+ end
425
+
426
+ def apple_domain_association_file_content
427
+ @apple_domain_association_file_content ||= apple_developer_merchantid_domain_association&.download
428
+ end
429
+
430
+ private
431
+
432
+ def validate_api_key
433
+ return if preferred_api_key.blank?
434
+
435
+ get_api_credential_details
436
+ rescue Adyen::AuthenticationError => e
437
+ errors.add(:preferred_api_key, "is invalid. Response: #{e.msg}")
438
+ rescue Adyen::PermissionError => e
439
+ errors.add(:preferred_api_key, "has insufficient permissions. Add missing roles to API credential. Response: #{e.msg}")
440
+ rescue Adyen::AdyenError => e
441
+ errors.add(:preferred_api_key, "An error occurred. Response: #{e.msg}")
442
+ end
443
+
444
+ def configure
445
+ return if preferred_api_key.blank?
446
+
447
+ SpreeAdyen::Gateways::Configure.new(self).call
448
+ end
449
+
450
+ def client
451
+ @client ||= Adyen::Client.new.tap do |client|
452
+ client.api_key = preferred_api_key
453
+ client.env = environment
454
+ client.live_url_prefix = preferred_live_url_prefix if environment == :live
455
+ end
456
+ end
457
+
458
+ def send_request
459
+ yield
460
+ rescue Adyen::AdyenError => e
461
+ raise Spree::Core::GatewayError, e.msg
462
+ end
463
+
464
+ def parse_adyen_error_response(error)
465
+ JSON.parse(error.response)
466
+ rescue JSON::ParserError, TypeError
467
+ { 'message' => error.msg }
468
+ end
469
+
470
+ def valid_hmac?(webhook_request_item)
471
+ hmac_keys = [preferred_hmac_key, previous_hmac_key].compact
472
+ return false if hmac_keys.empty?
473
+
474
+ hmac_keys.any? do |key|
475
+ Adyen::Utils::HmacValidator.new.valid_webhook_hmac?(webhook_request_item, key)
476
+ end
477
+ end
478
+
479
+ def success(authorization, full_response)
480
+ Spree::PaymentResponse.new(true, nil, full_response.as_json, authorization: authorization)
481
+ end
482
+
483
+ def failure(error = nil)
484
+ Spree::PaymentResponse.new(false, error)
485
+ end
486
+ end
487
+ end
@@ -0,0 +1,23 @@
1
+ module SpreeAdyen
2
+ module OrderDecorator
3
+ def self.prepended(base)
4
+ base.has_many :adyen_payment_sessions, class_name: 'SpreeAdyen::PaymentSession', dependent: :destroy
5
+ end
6
+
7
+ def outdate_payment_sessions
8
+ return unless SpreeAdyen::Config[:use_legacy_adyen_payment_sessions]
9
+
10
+ adyen_payment_sessions
11
+ .where.not(currency: currency).or(adyen_payment_sessions.where.not(amount: total_minus_store_credits))
12
+ .with_status(:initial)
13
+ .each(&:destroy)
14
+ end
15
+
16
+ def can_create_adyen_payment_session?
17
+ state.in?(%w[confirm payment])
18
+ end
19
+ end
20
+ end
21
+
22
+ Spree::Order.prepend(SpreeAdyen::OrderDecorator)
23
+ Spree::Order.register_update_hook :outdate_payment_sessions