diff --git a/lib/active_merchant/billing/gateways/reach.rb b/lib/active_merchant/billing/gateways/reach.rb index f9e71b04a5c..0fc3d8d0adf 100644 --- a/lib/active_merchant/billing/gateways/reach.rb +++ b/lib/active_merchant/billing/gateways/reach.rb @@ -42,9 +42,20 @@ def authorize(money, payment, options = {}) request = build_checkout_request(money, payment, options) add_custom_fields_data(request, options) add_customer_data(request, options, payment) - - post = { request: request, card: add_payment(payment) } - commit('checkout', post) + add_stored_credentials(request, options) + post = { request: request, card: add_payment(payment, options) } + if options[:stored_credential] + MultiResponse.run(:use_first_response) do |r| + r.process { commit('checkout', post) } + r.process do + r2 = get_network_payment_reference(r.responses[0]) + r.params[:network_transaction_id] = r2.message + r2 + end + end + else + commit('checkout', post) + end end def purchase(money, payment, options = {}) @@ -63,7 +74,7 @@ def supports_scrubbing? def scrub(transcript) transcript. - gsub(%r(((MerchantId%22%3A%22)[\w-]+)), '\2[FILTERED]'). + gsub(%r(((MerchantId)[% \w]+[%]\d{2})[\w -]+), '\1[FILTERED]'). gsub(%r((signature=)[\w%]+), '\1[FILTERED]\2'). gsub(%r((Number%22%3A%22)[\d]+), '\1[FILTERED]\2'). gsub(%r((VerificationCode%22%3A)[\d]+), '\1[FILTERED]\2') @@ -109,18 +120,18 @@ def build_checkout_request(amount, payment, options) Sku: options[:item_sku] || SecureRandom.alphanumeric, ConsumerPrice: amount, Quantity: (options[:item_quantity] || 1) - ], - ViaAgent: true # Indicates this is server to server API call + ] } end - def add_payment(payment) + def add_payment(payment, options) + ntid = options.dig(:stored_credential, :network_transaction_id) + cvv_or_previos_reference = (ntid ? { PreviousNetworkPaymentReference: ntid } : { VerificationCode: payment.verification_value }) { Name: payment.name, Number: payment.number, - Expiry: { Month: payment.month, Year: payment.year }, - VerificationCode: payment.verification_value - } + Expiry: { Month: payment.month, Year: payment.year } + }.merge!(cvv_or_previos_reference) end def add_customer_data(request, options, payment) @@ -137,11 +148,41 @@ def add_customer_data(request, options, payment) }.compact end + def add_stored_credentials(request, options) + request[:PaymentModel] = payment_model(options) + raise ArgumentError, 'Unexpected combination of stored credential fields' if request[:PaymentModel].nil? + + request[:DeviceFingerprint] = options[:device_fingerprint] if options[:device_fingerprint] && request[:PaymentModel].match?(/CIT-/) + end + + def payment_model(options) + stored_credential = options[:stored_credential] + return options[:payment_model] if options[:payment_model] + return 'CIT-One-Time' unless stored_credential + + payment_model_options = { + initial_transaction: { + 'cardholder' => { + 'installment' => 'CIT-Setup-Scheduled', + 'unschedule' => 'CIT-Setup-Unscheduled-MIT', + 'recurring' => 'CIT-Setup-Unschedule' + } + }, + no_initial_transaction: { + 'cardholder' => { + 'unschedule' => 'CIT-Subsequent-Unscheduled' + }, + 'merchant' => { + 'recurring' => 'MIT-Subsequent-Scheduled', + 'unschedule' => 'MIT-Subsequent-Unscheduled' + } + } + } + initial = (stored_credential[:initial_transaction] ? :initial_transaction : :no_initial_transaction) + payment_model_options[initial].dig(stored_credential[:initiator], stored_credential[:reason_type]) + end + def add_custom_fields_data(request, options) - if options[:device_fingerprint].present? - request[:DeviceFingerprint] = options[:device_fingerprint] - request[:ViaAgent] = false - end add_shipping_data(request, options) if options[:taxes].present? request[:RateOfferId] = options[:rate_offer_id] if options[:rate_offer_id].present? request[:Items] = options[:items] if options[:items].present? @@ -179,6 +220,16 @@ def format_and_sign(post) post end + def get_network_payment_reference(response) + parameters = { request: { MerchantId: @options[:merchant_id], OrderId: response.params['response'][:OrderId] } } + body = post_data format_and_sign(parameters) + + raw_response = ssl_request :post, url('query'), body, {} + response = parse(raw_response) + message = response.dig(:response, :Payment, :NetworkPaymentReference) + Response.new(true, message, {}) + end + def commit(action, parameters) body = post_data format_and_sign(parameters) raw_response = ssl_post url(action), body diff --git a/test/remote/gateways/remote_reach_test.rb b/test/remote/gateways/remote_reach_test.rb index f996a27df02..35a3d53d96c 100644 --- a/test/remote/gateways/remote_reach_test.rb +++ b/test/remote/gateways/remote_reach_test.rb @@ -22,7 +22,8 @@ def setup state: 'FL', zip: '32191', country: 'US' - } + }, + device_fingerprint: fingerprint } @non_valid_authorization = SecureRandom.uuid end @@ -141,6 +142,48 @@ def test_successful_authorize_and_capture assert_success response end + def test_successful_purchase_with_store_credentials + @options[:stored_credential] = { initiator: 'cardholder', initial_transaction: true, reason_type: 'installment' } + response = @gateway.purchase(@amount, @credit_card, @options) + + assert_success response + + assert response.params['response'][:Authorized] + assert response.params['response'][:OrderId] + end + + def test_successful_purchase_with_store_credentials_mit + @options[:stored_credential] = { initiator: 'merchant', initial_transaction: false, reason_type: 'recurring' } + response = @gateway.purchase(@amount, @credit_card, @options) + + assert_success response + + assert response.params['response'][:Authorized] + assert response.params['response'][:OrderId] + end + + def test_successful_purchase_with_store_credentials_mit_and_network_transaction_id + @options[:stored_credential] = { initiator: 'cardholder', initial_transaction: true, reason_type: 'installment' } + purchase = @gateway.purchase(@amount, @credit_card, @options) + + @options[:stored_credential] = { initiator: 'merchant', initial_transaction: false, reason_type: 'unschedule', network_transaction_id: purchase.network_transaction_id } + response = @gateway.purchase(@amount, @credit_card, @options) + + assert_success response + + assert response.params['response'][:Authorized] + assert response.params['response'][:OrderId] + end + + def test_failed_purchase_with_store_credentials_mit_and_network_transaction_id + @options[:stored_credential] = { initiator: 'merchant', initial_transaction: false, reason_type: 'unschedule', network_transaction_id: 'uhh123' } + response = @gateway.purchase(@amount, @credit_card, @options) + + assert_failure response + + assert_equal response.message, 'InvalidPreviousNetworkPaymentReference' + end + def test_failed_capture response = @gateway.capture(@amount, "#{@gateway.options[:merchant_id]}#123") @@ -241,4 +284,11 @@ def test_transcript_scrubbing assert_scrubbed(@gateway.options[:merchant_id], transcript) assert_scrubbed(@gateway.options[:secret], transcript) end + + def fingerprint + raw_response = @gateway.ssl_get @gateway.send(:url, "fingerprint?MerchantId=#{@gateway.options[:merchant_id]}") + + fingerprint = raw_response.match(/(gip_device_fingerprint=')([\w -]+)/)[2] + fingerprint + end end diff --git a/test/unit/gateways/reach_test.rb b/test/unit/gateways/reach_test.rb index 5c0d50ee021..7aa5f946585 100644 --- a/test/unit/gateways/reach_test.rb +++ b/test/unit/gateways/reach_test.rb @@ -76,7 +76,6 @@ def test_successfully_build_a_purchase_with_fingerprint end.check_request do |_endpoint, data, _headers| request = JSON.parse(URI.decode_www_form(data)[0][1]) assert_equal request['DeviceFingerprint'], @options[:device_fingerprint] - assert_equal request['ViaAgent'], false end.respond_with(successful_purchase_response) end @@ -130,6 +129,56 @@ def test_sucess_from_on_failure refute @gateway.send(:success_from, response) end + def test_stored_credential + cases = + [ + { { initial_transaction: true, initiator: 'cardholder', reason_type: 'installment' } => 'CIT-Setup-Scheduled' }, + { { initial_transaction: true, initiator: 'cardholder', reason_type: 'unschedule' } => 'CIT-Setup-Unscheduled-MIT' }, + { { initial_transaction: true, initiator: 'cardholder', reason_type: 'recurring' } => 'CIT-Setup-Unschedule' }, + { { initial_transaction: false, initiator: 'cardholder', reason_type: 'unschedule' } => 'CIT-Subsequent-Unscheduled' }, + { { initial_transaction: false, initiator: 'merchant', reason_type: 'recurring' } => 'MIT-Subsequent-Scheduled' }, + { { initial_transaction: false, initiator: 'merchant', reason_type: 'unschedule' } => 'MIT-Subsequent-Unscheduled' } + ] + + cases.each do |stored_credential_case| + stored_credential_options = stored_credential_case.keys[0] + expected = stored_credential_case[stored_credential_options] + @options[:stored_credential] = stored_credential_options + stub_comms do + @gateway.expects(:ssl_request).returns(succesful_query_response) + @gateway.purchase(@amount, @credit_card, @options) + end.check_request do |_endpoint, data, _headers| + request = JSON.parse(URI.decode_www_form(data)[0][1]) + assert_equal expected, request['PaymentModel'] + end.respond_with(successful_purchase_response) + end + end + + def test_stored_credential_with_no_store_credential_parameters + stub_comms do + @gateway.purchase(@amount, @credit_card, @options) + end.check_request do |_endpoint, data, _headers| + request = JSON.parse(URI.decode_www_form(data)[0][1]) + assert_equal 'CIT-One-Time', request['PaymentModel'] + end.respond_with(successful_purchase_response) + end + + def test_stored_credential_with_wrong_combination_stored_credential_paramaters + @options[:stored_credential] = { initiator: 'merchant', initial_transaction: true, reason_type: 'unschedule' } + e = assert_raise ArgumentError do + @gateway.purchase(@amount, @credit_card, @options) + end + assert_equal e.message, 'Unexpected combination of stored credential fields' + end + + def test_stored_credential_with_at_lest_one_stored_credential_paramaters_nil + @options[:stored_credential] = { initiator: 'merchant', initial_transaction: true, reason_type: nil } + e = assert_raise ArgumentError do + @gateway.purchase(@amount, @credit_card, @options) + end + assert_equal e.message, 'Unexpected combination of stored credential fields' + end + def test_scrub assert @gateway.supports_scrubbing? @@ -141,6 +190,10 @@ def test_scrub def successful_purchase_response 'response=%7B%22OrderId%22%3A%22e8f8c529-15c7-46c1-b28b-9d43bb5efe92%22%2C%22UnderReview%22%3Afalse%2C%22Expiry%22%3A%222022-11-03T12%3A47%3A21Z%22%2C%22Authorized%22%3Atrue%2C%22Completed%22%3Afalse%2C%22Captured%22%3Afalse%7D&signature=JqLa7Y68OYRgRcA5ALHOZwXXzdZFeNzqHma2RT2JWAg%3D' end + + def succesful_query_response + 'response=%7B%22Meta%22%3A%20null%2C%20%22Rate%22%3A%201.000000000000%2C%20%22Items%22%3A%20%5B%7B%22Sku%22%3A%20%22RLaP7OsSZjbR2pJK%22%2C%20%22Quantity%22%3A%201%2C%20%22ConsumerPrice%22%3A%20100.00%2C%20%22MerchantPrice%22%3A%20100.00%7D%5D%2C%20%22Store%22%3A%20null%2C%20%22Times%22%3A%20%7B%22Created%22%3A%20%222022-12-05T17%3A48%3A18.830991Z%22%2C%20%22Processed%22%3A%20null%2C%20%22Authorized%22%3A%20%222022-12-05T17%3A48%3A19.855608Z%22%7D%2C%20%22Action%22%3A%20null%2C%20%22Expiry%22%3A%20%222022-12-12T17%3A48%3A19.855608Z%22%2C%20%22Reason%22%3A%20null%2C%20%22Charges%22%3A%20null%2C%20%22OrderId%22%3A%20%226ec68268-a4a5-44dd-8997-e76df4aa9c97%22%2C%20%22Payment%22%3A%20%7B%22Class%22%3A%20%22Card%22%2C%20%22Expiry%22%3A%20%222030-03%22%2C%20%22Method%22%3A%20%22VISA%22%2C%20%22AccountIdentifier%22%3A%20%22444433******1111%22%2C%20%22NetworkPaymentReference%22%3A%20%22546646904394415%22%7D%2C%20%22Refunds%22%3A%20%5B%5D%2C%20%22Consumer%22%3A%20%7B%22City%22%3A%20%22Miami%22%2C%20%22Name%22%3A%20%22Longbob%20Longsen%22%2C%20%22Email%22%3A%20%22johndoe%40reach.com%22%2C%20%22Address%22%3A%20%221670%22%2C%20%22Country%22%3A%20%22US%22%2C%20%22EffectiveIpAddress%22%3A%20%22181.78.14.203%22%7D%2C%20%22Shipping%22%3A%20null%2C%20%22Consignee%22%3A%20null%2C%20%22Discounts%22%3A%20null%2C%20%22Financing%22%3A%20null%2C%20%22Chargeback%22%3A%20false%2C%20%22ContractId%22%3A%20null%2C%20%22MerchantId%22%3A%20%22testMerchantId%22%2C%20%22OrderState%22%3A%20%22PaymentAuthorized%22%2C%20%22RateOfferId%22%3A%20%22c754012f-e0fc-4630-9cb5-11c3450f462e%22%2C%20%22ReferenceId%22%3A%20%22123%22%2C%20%22UnderReview%22%3A%20false%2C%20%22ConsumerTotal%22%3A%20100.00%2C%20%22MerchantTotal%22%3A%20100.00%2C%20%22TransactionId%22%3A%20%22e08f6501-2607-4be1-9dba-97d6780dfe9a%22%2C%20%22ConsumerCurrency%22%3A%20%22USD%22%7D&signature=no%2BEojgxrO5JK4wt4EWtbuY9M7h1eVQ9SLezu10X%2Bn4%3D' + end end def pre_scrubbed