Skip to content

Commit

Permalink
Reach: Add stored credential support (#4636)
Browse files Browse the repository at this point in the history
Summary:
------------------------------
In order to be able to store credentials, this commit
adds add_stored_credentials method, and get_network_payment_reference
to be used in network_transaction_id

Remote Tests:
------------------------------
Finished in 36.906747 seconds.
16 tests, 42 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

Unit Tests:
------------------------------
Finished in 2.793742 seconds.
16 tests, 75 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

Rubocop:
------------------------------
753 files inspected, no offenses detected

Co-authored-by: Gustavo Sanmartin <[email protected]>
  • Loading branch information
gasb150 and Gustavo Sanmartin authored Dec 7, 2022
1 parent 8b3d833 commit 64427ce
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 16 deletions.
79 changes: 65 additions & 14 deletions lib/active_merchant/billing/gateways/reach.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {})
Expand All @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion test/remote/gateways/remote_reach_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def setup
state: 'FL',
zip: '32191',
country: 'US'
}
},
device_fingerprint: fingerprint
}
@non_valid_authorization = SecureRandom.uuid
end
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
55 changes: 54 additions & 1 deletion test/unit/gateways/reach_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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?

Expand All @@ -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
Expand Down

0 comments on commit 64427ce

Please sign in to comment.