diff --git a/CHANGELOG b/CHANGELOG index 4b021de8d83..0d065137a14 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -92,6 +92,10 @@ * Orbital: Remove `DPANInd` field for RC transactions [ajawadmirza] #4502 * EBANX: Add Spreedly tag to payment body [flaaviaa] #4527 * Shift4: Add `expiration_date` field for refund transactions [ajawadmirza] #4532 +* Improve handling of AVS and CVV Results in Multiresponses [gasb150] #4516 +* Airwallex: Add `skip_3ds` field for create payment transactions [ajawadmirza] #4534 +* Shift4: Typo correction for `initial_transaction` [ajawadmirza] #4537 +* Rapyd: Pass Customer ID and fix `add_token` method [naashton] #4538 == Version 1.126.0 (April 15th, 2022) * Moneris: Add 3DS MPI field support [esmitperez] #4373 diff --git a/lib/active_merchant/billing/gateways/airwallex.rb b/lib/active_merchant/billing/gateways/airwallex.rb index d7a603eca92..e60f5d8de96 100644 --- a/lib/active_merchant/billing/gateways/airwallex.rb +++ b/lib/active_merchant/billing/gateways/airwallex.rb @@ -154,6 +154,7 @@ def create_payment_intent(money, options = {}) post[:merchant_order_id] = merchant_order_id(options) add_referrer_data(post) add_descriptor(post, options) + post['payment_method_options'] = { 'card' => { 'risk_control' => { 'three_ds_action' => 'SKIP_3DS' } } } if options[:skip_3ds] response = commit(:setup, post) raise ArgumentError.new(response.message) unless response.success? diff --git a/lib/active_merchant/billing/gateways/card_connect.rb b/lib/active_merchant/billing/gateways/card_connect.rb index 81c749174c4..151abde8e18 100644 --- a/lib/active_merchant/billing/gateways/card_connect.rb +++ b/lib/active_merchant/billing/gateways/card_connect.rb @@ -1,8 +1,8 @@ module ActiveMerchant #:nodoc: module Billing #:nodoc: class CardConnectGateway < Gateway - self.test_url = 'https://fts.cardconnect.com:6443/cardconnect/rest/' - self.live_url = 'https://fts.cardconnect.com:8443/cardconnect/rest/' + self.test_url = 'https://fts-uat.cardconnect.com/cardconnect/rest/' + self.live_url = 'https://fts.cardconnect.com/cardconnect/rest/' self.supported_countries = ['US'] self.default_currency = 'USD' diff --git a/lib/active_merchant/billing/gateways/rapyd.rb b/lib/active_merchant/billing/gateways/rapyd.rb index 41a6830a11e..81aa2864045 100644 --- a/lib/active_merchant/billing/gateways/rapyd.rb +++ b/lib/active_merchant/billing/gateways/rapyd.rb @@ -28,6 +28,7 @@ def purchase(money, payment, options = {}) add_ewallet(post, options) add_payment_fields(post, options) add_payment_urls(post, options) + add_customer_id(post, options) post[:capture] = true if payment.is_a?(CreditCard) if payment.is_a?(Check) @@ -53,6 +54,7 @@ def authorize(money, payment, options = {}) add_ewallet(post, options) add_payment_fields(post, options) add_payment_urls(post, options) + add_customer_id(post, options) post[:capture] = false commit(:post, 'payments', post) @@ -185,7 +187,9 @@ def add_ach(post, payment, options) end def add_token(post, payment, options) - post[:payment_method] = payment + return unless token = payment.split('|')[1] + + post[:payment_method] = token end def add_3ds(post, payment, options) @@ -226,6 +230,10 @@ def add_customer_object(post, payment, options) post[:email] = options[:email] if options[:email] end + def add_customer_id(post, options) + post[:customer] = options[:customer_id] if options[:customer_id] + end + def parse(body) return {} if body.empty? || body.nil? diff --git a/lib/active_merchant/billing/gateways/shift4.rb b/lib/active_merchant/billing/gateways/shift4.rb index 79874428348..f386a1448d9 100644 --- a/lib/active_merchant/billing/gateways/shift4.rb +++ b/lib/active_merchant/billing/gateways/shift4.rb @@ -223,7 +223,7 @@ def add_card_on_file(post, options) return unless stored_credential = options[:stored_credential] post[:cardOnFile] = {} - post[:cardOnFile][:usageIndicator] = stored_credential[:inital_transaction] ? '01' : '02' + post[:cardOnFile][:usageIndicator] = stored_credential[:initial_transaction] ? '01' : '02' post[:cardOnFile][:indicator] = options[:card_on_file_indicator] || '01' post[:cardOnFile][:scheduledIndicator] = RECURRING_TYPE_TRANSACTIONS.include?(stored_credential[:reason_type]) ? '01' : '02' if stored_credential[:reason_type] post[:cardOnFile][:transactionId] = stored_credential[:network_transaction_id] if stored_credential[:network_transaction_id] diff --git a/lib/active_merchant/billing/response.rb b/lib/active_merchant/billing/response.rb index 1d4b7fd5ceb..fb1c502d7ee 100644 --- a/lib/active_merchant/billing/response.rb +++ b/lib/active_merchant/billing/response.rb @@ -85,7 +85,21 @@ def success? (primary_response ? primary_response.success? : true) end - %w(params message test authorization avs_result cvv_result error_code emv_authorization test? fraud_review?).each do |m| + def avs_result + return @primary_response.try(:avs_result) if @use_first_response + + result = responses.reverse.find { |r| r.avs_result['code'].present? } + result.try(:avs_result) || responses.last.try(:avs_result) + end + + def cvv_result + return @primary_response.try(:cvv_result) if @use_first_response + + result = responses.reverse.find { |r| r.cvv_result['code'].present? } + result.try(:cvv_result) || responses.last.try(:cvv_result) + end + + %w(params message test authorization error_code emv_authorization test? fraud_review?).each do |m| class_eval %( def #{m} (@responses.empty? ? nil : primary_response.#{m}) diff --git a/test/remote/gateways/remote_airwallex_test.rb b/test/remote/gateways/remote_airwallex_test.rb index de232aeb871..5cbc4053c7d 100644 --- a/test/remote/gateways/remote_airwallex_test.rb +++ b/test/remote/gateways/remote_airwallex_test.rb @@ -41,6 +41,12 @@ def test_successful_purchase_with_specified_ids assert_match(merchant_order_id, response.params.dig('merchant_order_id')) end + def test_successful_purchase_with_skip_3ds + response = @gateway.purchase(@amount, @credit_card, @options.merge({ skip_3ds: 'true' })) + assert_success response + assert_equal 'AUTHORIZED', response.message + end + def test_failed_purchase response = @gateway.purchase(@declined_amount, @declined_card, @options) assert_failure response diff --git a/test/remote/gateways/remote_card_connect_test.rb b/test/remote/gateways/remote_card_connect_test.rb index 57de6f6cf9f..8b8582e3b56 100644 --- a/test/remote/gateways/remote_card_connect_test.rb +++ b/test/remote/gateways/remote_card_connect_test.rb @@ -201,22 +201,34 @@ def test_failed_echeck_purchase assert_equal 'Invalid card', response.message end - def test_successful_refund - purchase = @gateway.purchase(@amount, @credit_card, @options) - assert_success purchase - - assert refund = @gateway.refund(@amount, purchase.authorization) - assert_success refund - assert_equal 'Approval', refund.message - end - - def test_partial_refund - purchase = @gateway.purchase(@amount, @credit_card, @options) - assert_success purchase - - assert refund = @gateway.refund(@amount - 1, purchase.authorization) - assert_success refund - end + # A transaction cannot be refunded before settlement so these tests will fail with the following response + # { + # "respproc"=>"PPS", + # "amount"=>"0.00", + # "resptext"=>"Txn not settled", + # "currency"=>"USD", + # "retref"=>"222509002106", + # "respstat"=>"C", + # "respcode"=>"28", + # "merchid"=>"496160873888" + # } + + # def test_successful_refund + # purchase = @gateway.purchase(@amount, @credit_card, @options) + # assert_success purchase + + # assert refund = @gateway.refund(@amount, purchase.authorization) + # assert_success refund + # assert_equal 'Approval', refund.message + # end + + # def test_partial_refund + # purchase = @gateway.purchase(@amount, @credit_card, @options) + # assert_success purchase + + # assert refund = @gateway.refund(@amount - 1, purchase.authorization) + # assert_success refund + # end def test_failed_refund response = @gateway.refund(@amount, @invalid_txn) diff --git a/test/remote/gateways/remote_rapyd_test.rb b/test/remote/gateways/remote_rapyd_test.rb index 3d1c05fdb57..775953d8013 100644 --- a/test/remote/gateways/remote_rapyd_test.rb +++ b/test/remote/gateways/remote_rapyd_test.rb @@ -9,7 +9,7 @@ def setup @declined_card = credit_card('4111111111111105') @check = check @options = { - pm_type: 'us_visa_card', + pm_type: 'us_debit_visa_card', currency: 'USD', complete_payment_url: 'www.google.com', error_payment_url: 'www.google.com', @@ -204,6 +204,17 @@ def test_failed_verify assert_equal 'Do Not Honor', response.message end + def test_successful_store_and_purchase + store = @gateway.store(@credit_card, @options) + assert_success store + assert store.params.dig('data', 'id') + assert store.params.dig('data', 'default_payment_method') + + # 3DS authorization is required on storing a payment method for future transactions + # purchase = @gateway.purchase(100, store.authorization, @options.merge(customer_id: customer_id)) + # assert_sucess purchase + end + def test_successful_store_and_unstore store = @gateway.store(@credit_card, @options) assert_success store diff --git a/test/remote/gateways/remote_shift4_test.rb b/test/remote/gateways/remote_shift4_test.rb index ef1b7554849..3d72f6f3f91 100644 --- a/test/remote/gateways/remote_shift4_test.rb +++ b/test/remote/gateways/remote_shift4_test.rb @@ -55,7 +55,7 @@ def test_successful_purchase_with_extra_options def test_successful_purchase_with_stored_credential stored_credential_options = { - inital_transaction: true, + initial_transaction: true, reason_type: 'recurring' } first_response = @gateway.purchase(@amount, @credit_card, @options.merge(@extra_options.merge({ stored_credential: stored_credential_options }))) @@ -63,7 +63,7 @@ def test_successful_purchase_with_stored_credential ntxid = first_response.params['result'].first['transaction']['cardOnFile']['transactionId'] stored_credential_options = { - inital_transaction: false, + initial_transaction: false, reason_type: 'recurring', network_transaction_id: ntxid } diff --git a/test/unit/gateways/airwallex_test.rb b/test/unit/gateways/airwallex_test.rb index a3115d8de53..ade541ce88e 100644 --- a/test/unit/gateways/airwallex_test.rb +++ b/test/unit/gateways/airwallex_test.rb @@ -157,6 +157,22 @@ def test_successful_purchase_with_3ds_version_formatting assert_equal 'AUTHORIZED', response.message end + def test_successful_skip_3ds_in_payment_intent + stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge({ skip_3ds: true })) + end.check_request do |endpoint, data, _headers| + data = JSON.parse(data) + assert_match(data['payment_method_options']['card']['risk_control']['three_ds_action'], 'SKIP_3DS') if endpoint == setup_endpoint + end.respond_with(successful_purchase_response) + + stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge({ skip_3ds: 'true' })) + end.check_request do |endpoint, data, _headers| + data = JSON.parse(data) + assert_match(data['payment_method_options']['card']['risk_control']['three_ds_action'], 'SKIP_3DS') if endpoint == setup_endpoint + end.respond_with(successful_purchase_response) + end + def test_successful_capture @gateway.expects(:ssl_post).returns(successful_capture_response) diff --git a/test/unit/gateways/rapyd_test.rb b/test/unit/gateways/rapyd_test.rb index 3c51e4f934d..55b01ef205b 100644 --- a/test/unit/gateways/rapyd_test.rb +++ b/test/unit/gateways/rapyd_test.rb @@ -7,6 +7,7 @@ def setup @gateway = RapydGateway.new(secret_key: 'secret_key', access_key: 'access_key') @credit_card = credit_card @amount = 100 + @authorization = 'cus_9e1b5a357b2b7f25f8dd98827fbc4f22|card_cf105df9e77462deb34ffef33c3e3d05' @options = { pm_type: 'in_amex_card', @@ -58,10 +59,16 @@ def test_successful_purchase_with_ach assert_equal 'ACT', response.params['data']['status'] end - def test_successful_purchase_with_options - @gateway.expects(:ssl_request).returns(successful_purchase_with_options_response) + def test_successful_purchase_with_token + @options.merge(customer_id: 'cus_9e1b5a357b2b7f25f8dd98827fbc4f22') + response = stub_comms(@gateway, :ssl_request) do + @gateway.purchase(@amount, @authorization, @options) + end.check_request do |_method, _endpoint, data, _headers| + request = JSON.parse(data) + assert_equal request['payment_method'], @authorization.split('|')[1] + assert_equal request['customer'], @options[:customer_id] + end.respond_with(successful_purchase_with_options_response) - response = @gateway.purchase(@amount, @credit_card, @options.merge(metadata: @metadata)) assert_success response assert_equal @metadata, response.params['data']['metadata'].deep_transform_keys(&:to_sym) end diff --git a/test/unit/gateways/shift4_test.rb b/test/unit/gateways/shift4_test.rb index 75b19a2bd8d..402e6ee128c 100644 --- a/test/unit/gateways/shift4_test.rb +++ b/test/unit/gateways/shift4_test.rb @@ -77,22 +77,35 @@ def test_successful_purchase_with_extra_fields def test_successful_purchase_with_stored_credential stored_credential_options = { - inital_transaction: false, - reason_type: 'recurring', - network_transaction_id: '123abcdefg' + initial_transaction: true, + reason_type: 'recurring' } response = stub_comms do @gateway.purchase(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_options })) end.check_request do |_endpoint, data, _headers| request = JSON.parse(data)['transaction'] - assert_equal request['cardOnFile']['usageIndicator'], '02' + assert_equal request['cardOnFile']['usageIndicator'], '01' assert_equal request['cardOnFile']['indicator'], '01' assert_equal request['cardOnFile']['scheduledIndicator'], '01' - assert_equal request['cardOnFile']['transactionId'], stored_credential_options[:network_transaction_id] + assert_nil request['cardOnFile']['transactionId'] end.respond_with(successful_purchase_response) assert response.success? assert_equal response.message, 'Transaction successful' + + stored_credential_options = { + reason_type: 'recurring', + network_transaction_id: '123abcdefg' + } + stub_comms do + @gateway.purchase(@amount, @credit_card, @options.merge({ stored_credential: stored_credential_options })) + end.check_request do |_endpoint, data, _headers| + request = JSON.parse(data)['transaction'] + assert_equal request['cardOnFile']['usageIndicator'], '02' + assert_equal request['cardOnFile']['indicator'], '01' + assert_equal request['cardOnFile']['scheduledIndicator'], '01' + assert_equal request['cardOnFile']['transactionId'], stored_credential_options[:network_transaction_id] + end.respond_with(successful_purchase_response) end def test_successful_store diff --git a/test/unit/multi_response_test.rb b/test/unit/multi_response_test.rb index 2304bbb9a34..ed7c1935fd2 100644 --- a/test/unit/multi_response_test.rb +++ b/test/unit/multi_response_test.rb @@ -172,4 +172,94 @@ def test_handles_ignores_optional_request_result assert m.success? end + + def test_handles_responses_with_only_one_with_avs_and_cvv_result + r1 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'Y'), cvv_result: 'M' }) + r2 = Response.new(true, '2', {}) + m = MultiResponse.run do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => 'Y', 'message' => 'Street address and 5-digit postal code match.', 'street_match' => 'Y', 'postal_match' => 'Y' } + assert_equal m.cvv_result, { 'code' => 'M', 'message' => 'CVV matches' } + end + + def test_handles_responses_using_last_response_cvv_and_avs_result + r1 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'Y'), cvv_result: 'M' }) + r2 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'B'), cvv_result: 'N' }) + m = MultiResponse.run do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => 'B', 'message' => 'Street address matches, but postal code not verified.', 'street_match' => 'Y', 'postal_match' => nil } + assert_equal m.cvv_result, { 'code' => 'N', 'message' => 'CVV does not match' } + end + + def test_handles_responses_using_first_response_cvv_and_avs_result + r1 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'Y'), cvv_result: 'M' }) + r2 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'B'), cvv_result: 'N' }) + m = MultiResponse.run(:use_first_response) do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => 'Y', 'message' => 'Street address and 5-digit postal code match.', 'street_match' => 'Y', 'postal_match' => 'Y' } + assert_equal m.cvv_result, { 'code' => 'M', 'message' => 'CVV matches' } + end + + def test_handles_responses_using_first_response_cvv_that_no_has_cvv_and_avs_result + r1 = Response.new(true, '1', {}) + r2 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'B'), cvv_result: 'N' }) + m = MultiResponse.run(:use_first_response) do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => nil, 'message' => nil, 'street_match' => nil, 'postal_match' => nil } + assert_equal m.cvv_result, { 'code' => nil, 'message' => nil } + end + + def test_handles_response_with_avs_and_without_cvv_result + r1 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: 'X'), cvv_result: CVVResult.new(nil) }) + r2 = Response.new(true, '2', {}) + m = MultiResponse.run do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => 'X', 'message' => 'Street address and 9-digit postal code match.', 'street_match' => 'Y', 'postal_match' => 'Y' } + assert_equal m.cvv_result, { 'code' => nil, 'message' => nil } + end + + def test_handles_response_avs_and_cvv_result_with_wrong_values_avs_and_cvv_code + r1 = Response.new(true, '1', {}, { avs_result: AVSResult.new(code: '1234567'), cvv_result: CVVResult.new('987654') }) + r2 = Response.new(true, '2', {}) + m = MultiResponse.run do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => '1234567', 'message' => nil, 'street_match' => nil, 'postal_match' => nil } + assert_equal m.cvv_result, { 'code' => '987654', 'message' => nil } + end + + def test_handles_response_without_avs_and_cvv_result + r1 = Response.new(true, '1', {}) + r2 = Response.new(true, '2', {}) + m = MultiResponse.run do |r| + r.process { r1 } + r.process { r2 } + end + assert_equal [r1, r2], m.responses + assert_equal m.avs_result, { 'code' => nil, 'message' => nil, 'street_match' => nil, 'postal_match' => nil } + assert_equal m.cvv_result, { 'code' => nil, 'message' => nil } + end + + def test_handles_responses_avs_and_cvv_result_with_no_responses_provideds + m = MultiResponse.new + assert_equal m.avs_result, nil + assert_equal m.cvv_result, nil + end end