From d6230be560a7ddfc44bb4b5017c7c1d88f8ddbfd Mon Sep 17 00:00:00 2001 From: Gustavo Sanmartin Date: Tue, 30 Apr 2024 16:15:59 -0500 Subject: [PATCH] Datatrans: NT, AP, GP support Summary: ----- This PR includes for Datatrans the NetworkToken, ApplePay and GooglePay code to perform transactions with that PM [SER-1195](https://spreedly.atlassian.net/browse/SER-1195) Tests ----- Remote Test: Finished in 23.393965 seconds. 19 tests, 51 assertions, 0 failures, 0 errors, 0 pendings, 1 omissions, 0 notifications 100% passed Unit Tests: 23 tests, 111 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed RuboCop: 795 files inspected, no offenses detected --- .../billing/gateways/datatrans.rb | 102 ++++--- test/remote/gateways/remote_datatrans_test.rb | 84 ++++-- test/unit/gateways/datatrans_test.rb | 259 ++++++++++-------- 3 files changed, 270 insertions(+), 175 deletions(-) diff --git a/lib/active_merchant/billing/gateways/datatrans.rb b/lib/active_merchant/billing/gateways/datatrans.rb index 9fa2b035e52..f5ba9bfc867 100644 --- a/lib/active_merchant/billing/gateways/datatrans.rb +++ b/lib/active_merchant/billing/gateways/datatrans.rb @@ -15,6 +15,16 @@ class DatatransGateway < Gateway self.homepage_url = 'https://www.datatrans.ch/' self.display_name = 'Datatrans' + CREDIT_CARD_SOURCE = { + visa: 'VISA', + master: 'MASTERCARD' + }.with_indifferent_access + + DEVICE_SOURCE = { + apple_pay: 'APPLE_PAY', + google_pay: 'GOOGLE_PAY' + }.with_indifferent_access + def initialize(options = {}) requires!(options, :merchant_id, :password) @merchant_id, @password = options.values_at(:merchant_id, :password) @@ -26,8 +36,8 @@ def purchase(money, payment, options = {}) end def authorize(money, payment, options = {}) - post = add_payment_method(payment) - post[:refno] = options[:order_id].to_s if options[:order_id] + post = { refno: options.fetch(:order_id, '') } + add_payment_method(post, payment) add_currency_amount(post, money, options) add_billing_address(post, options) post[:autoSettle] = options[:auto_settle] if options[:auto_settle] @@ -35,23 +45,23 @@ def authorize(money, payment, options = {}) end def capture(money, authorization, options = {}) - post = { refno: options[:order_id]&.to_s } - transaction_id, = authorization.split('|') + post = { refno: options.fetch(:order_id, '') } + transaction_id = authorization.split('|').first add_currency_amount(post, money, options) - commit('settle', post, { transaction_id: transaction_id, authorization: authorization }) + commit('settle', post, { transaction_id: transaction_id }) end def refund(money, authorization, options = {}) - post = { refno: options[:order_id]&.to_s } - transaction_id, = authorization.split('|') + post = { refno: options.fetch(:order_id, '') } + transaction_id = authorization.split('|').first add_currency_amount(post, money, options) commit('credit', post, { transaction_id: transaction_id }) end def void(authorization, options = {}) post = {} - transaction_id, = authorization.split('|') - commit('cancel', post, { transaction_id: transaction_id, authorization: authorization }) + transaction_id = authorization.split('|').first + commit('cancel', post, { transaction_id: transaction_id }) end def supports_scrubbing? @@ -67,21 +77,38 @@ def scrub(transcript) private - def add_payment_method(payment_method) - { - card: { + def add_payment_method(post, payment_method) + card = build_card(payment_method) + post[:card] = { + expiryMonth: format(payment_method.month, :two_digits), + expiryYear: format(payment_method.year, :two_digits) + }.merge(card) + end + + def build_card(payment_method) + if payment_method.is_a?(NetworkTokenizationCreditCard) + type_nt, token_type = if DEVICE_SOURCE[payment_method.source] + ['DEVICE_TOKEN', DEVICE_SOURCE[payment_method.source]] + else + ['NETWORK_TOKEN', CREDIT_CARD_SOURCE[card_brand(payment_method)]] + end + { + type: type_nt, + token: payment_method.number, + tokenType: token_type, + cryptogram: payment_method.payment_cryptogram + } + else + { number: payment_method.number, - cvv: payment_method.verification_value.to_s, - expiryMonth: format(payment_method.month, :two_digits), - expiryYear: format(payment_method.year, :two_digits) + cvv: payment_method.verification_value.to_s } - } + end end def add_billing_address(post, options) - return unless options[:billing_address] + return unless billing_address = options[:billing_address] - billing_address = options[:billing_address] post[:billing] = { name: billing_address[:name], street: billing_address[:address1], @@ -100,29 +127,32 @@ def add_currency_amount(post, money, options) end def commit(action, post, options = {}) - begin - raw_response = ssl_post(url(action, options), post.to_json, headers) - rescue ResponseError => e - raw_response = e.response.body - end - - response = parse(raw_response) - + response = parse(ssl_post(url(action, options), post.to_json, headers)) succeeded = success_from(action, response) + Response.new( succeeded, message_from(succeeded, response), response, - authorization: authorization_from(response, action, options), + authorization: authorization_from(response), test: test?, error_code: error_code_from(response) ) + rescue ResponseError => e + response = parse(e.response.body) + Response.new(false, message_from(false, response), response, test: test?, error_code: error_code_from(response)) end def parse(response) - return unless response - JSON.parse response + rescue JSON::ParserError + msg = 'Invalid JSON response received from Datatrans. Please contact them for support if you continue to receive this message.' + msg += " (The raw response returned by the API was #{response.inspect})" + { + 'successful' => false, + 'response' => {}, + 'errors' => [msg] + } end def headers @@ -144,21 +174,17 @@ def url(endpoint, options = {}) def success_from(action, response) case action when 'authorize', 'credit' - return true if response.include?('transactionId') && response.include?('acquirerAuthorizationCode') + true if response.include?('transactionId') && response.include?('acquirerAuthorizationCode') when 'settle', 'cancel' - return true if response.dig('response_code') == 204 + true if response.dig('response_code') == 204 else false end end - def authorization_from(response, action, options = {}) - case action - when 'settle' - options[:authorization] - else - [response['transactionId'], response['acquirerAuthorizationCode']].join('|') - end + def authorization_from(response) + auth = [response['transactionId'], response['acquirerAuthorizationCode']].join('|') + return auth unless auth == '|' end def message_from(succeeded, response) diff --git a/test/remote/gateways/remote_datatrans_test.rb b/test/remote/gateways/remote_datatrans_test.rb index c2dbe973b12..90bde7e34dc 100644 --- a/test/remote/gateways/remote_datatrans_test.rb +++ b/test/remote/gateways/remote_datatrans_test.rb @@ -16,36 +16,45 @@ def setup @billing_address = address - @execute_threed = { - execute_threed: true, - redirect_url: 'http://www.example.com/redirect', - callback_url: 'http://www.example.com/callback', - three_ds_2: { - browser_info: { - width: 390, - height: 400, - depth: 24, - timezone: 300, - user_agent: 'Spreedly Agent', - java: false, - javascript: true, - language: 'en-US', - browser_size: '05', - accept_header: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' - } - } - } + @google_pay_card = network_tokenization_credit_card( + '4900000000000094', + payment_cryptogram: 'YwAAAAAABaYcCMX/OhNRQAAAAAA=', + month: '06', + year: '2025', + source: :google_pay, + verification_value: 569 + ) + + @apple_pay_card = network_tokenization_credit_card( + '4900000000000094', + payment_cryptogram: 'YwAAAAAABaYcCMX/OhNRQAAAAAA=', + month: '06', + year: '2025', + source: :apple_pay, + verification_value: 569 + ) + + @nt_credit_card = network_tokenization_credit_card( + '4111111111111111', + payment_cryptogram: 'EHuWW9PiBkWvqE5juRwDzAUFBAk=', + eci: '07', + source: :network_token, + verification_value: '737', + brand: 'visa' + ) end def test_successful_authorize response = @gateway.authorize(@amount, @credit_card, @options) assert_success response + assert_include response.params, 'transactionId' end def test_successful_purchase response = @gateway.purchase(@amount, @credit_card, @options) assert_success response + assert_include response.params, 'transactionId' end def test_failed_authorize @@ -69,7 +78,7 @@ def test_successful_capture response = @gateway.capture(@amount, authorize_response.authorization, @options) assert_success response - assert_equal authorize_response.authorization, response.authorization + assert_equal response.authorization, nil end def test_successful_refund @@ -78,6 +87,7 @@ def test_successful_refund response = @gateway.refund(@amount, purchase_response.authorization, @options) assert_success response + assert_include response.params, 'transactionId' end def test_successful_capture_with_less_authorized_amount_and_refund @@ -86,9 +96,8 @@ def test_successful_capture_with_less_authorized_amount_and_refund capture_response = @gateway.capture(@amount - 100, authorize_response.authorization, @options) assert_success capture_response - assert_equal authorize_response.authorization, capture_response.authorization - response = @gateway.refund(@amount - 200, capture_response.authorization, @options) + response = @gateway.refund(@amount - 200, authorize_response.authorization, @options) assert_success response end @@ -99,7 +108,7 @@ def test_failed_partial_capture_already_captured capture_response = @gateway.capture(100, authorize_response.authorization, @options) assert_success capture_response - response = @gateway.capture(100, capture_response.authorization, @options) + response = @gateway.capture(100, authorize_response.authorization, @options) assert_failure response assert_equal response.error_code, 'INVALID_TRANSACTION_STATUS' assert_equal response.message, 'already settled' @@ -112,7 +121,7 @@ def test_failed_partial_capture_refund_refund_exceed_captured capture_response = @gateway.capture(100, authorize_response.authorization, @options) assert_success capture_response - response = @gateway.refund(200, capture_response.authorization, @options) + response = @gateway.refund(200, authorize_response.authorization, @options) assert_failure response assert_equal response.error_code, 'INVALID_PROPERTY' assert_equal response.message, 'credit.amount' @@ -154,6 +163,8 @@ def test_successful_void response = @gateway.void(authorize_response.authorization, @options) assert_success response + + assert_equal response.authorization, nil end def test_failed_void_because_captured_transaction @@ -184,4 +195,29 @@ def test_successful_purchase_with_billing_address assert_success response end + + def test_successful_purchase_with_network_token + response = @gateway.purchase(@amount, @nt_credit_card, @options) + + assert_success response + end + + def test_successful_purchase_with_apple_pay + response = @gateway.purchase(@amount, @apple_pay_card, @options) + + assert_success response + end + + def test_successful_authorize_with_google_pay + response = @gateway.authorize(@amount, @google_pay_card, @options) + assert_success response + end + + def test_successful_void_with_google_pay + authorize_response = @gateway.authorize(@amount, @google_pay_card, @options) + assert_success authorize_response + + response = @gateway.void(authorize_response.authorization, @options) + assert_success response + end end diff --git a/test/unit/gateways/datatrans_test.rb b/test/unit/gateways/datatrans_test.rb index 8949641db32..3dcafe90b76 100644 --- a/test/unit/gateways/datatrans_test.rb +++ b/test/unit/gateways/datatrans_test.rb @@ -13,125 +13,148 @@ def setup email: 'john.smith@test.com' } + @transaction_reference = '240214093712238757|093712' + @billing_address = address + + @nt_credit_card = network_tokenization_credit_card( + '4111111111111111', + payment_cryptogram: 'EHuWW9PiBkWvqE5juRwDzAUFBAk=', + eci: '07', + source: :network_token, + verification_value: '737', + brand: 'visa' + ) + + @apple_pay_card = network_tokenization_credit_card( + '4900000000000094', + payment_cryptogram: 'YwAAAAAABaYcCMX/OhNRQAAAAAA=', + month: '06', + year: '2025', + source: 'apple_pay', + verification_value: 569 + ) end def test_authorize_with_credit_card - @gateway.expects(:ssl_request). - with( - :post, - 'https://api.sandbox.datatrans.com/v1/transactions/authorize', - all_of( - regexp_matches(%r{"number\":\"(\d+{12})\"}), - regexp_matches(%r{"refno\":\"(\d+)\"}), - includes('"currency":"CHF"'), - includes('"amount":"100"') - ), - anything - ). - returns(successful_authorize_response) - - response = @gateway.authorize(@amount, @credit_card, @options) + response = stub_comms(@gateway, :ssl_request) do + @gateway.authorize(@amount, @credit_card, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('authorize', endpoint) + assert_match(/\"number\":\"(\d+{12})\"/, data) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + end.respond_with(successful_authorize_response) assert_success response end def test_authorize_with_credit_card_and_billing_address - @gateway.expects(:ssl_request). - with( - :post, - 'https://api.sandbox.datatrans.com/v1/transactions/authorize', - all_of( - regexp_matches(%r{"number\":\"(\d+{12})\"}), - regexp_matches(%r{"refno\":\"(\d+)\"}), - includes('"currency":"CHF"'), - includes('"amount":"100"'), - includes('"name":"Jim Smith"'), - includes('"street":"456 My Street"'), - includes('"street2":"Apt 1"'), - includes('"city":"Ottawa"'), - includes('"country":"CAN"'), - includes('"phoneNumber":"(555)555-5555"'), - includes('"zipCode":"K1C2N6"'), - includes('"email":"john.smith@test.com"') - ), - anything - ). - returns(successful_authorize_response) - - @gateway.authorize(@amount, @credit_card, @options.merge({ billing_address: @billing_address })) + response = stub_comms(@gateway, :ssl_request) do + @gateway.authorize(@amount, @credit_card, @options.merge({ billing_address: @billing_address })) + end.check_request do |_action, endpoint, data, _headers| + assert_match('authorize', endpoint) + assert_match(/\"number\":\"(\d+{12})\"/, data) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + assert_match('"name":"Jim Smith"', data) + assert_match('"street":"456 My Street"', data) + assert_match('"street2":"Apt 1"', data) + assert_match('"city":"Ottawa"', data) + assert_match('"country":"CAN"', data) + assert_match('"phoneNumber":"(555)555-5555"', data) + assert_match('"zipCode":"K1C2N6"', data) + assert_match('"email":"john.smith@test.com"', data) + end.respond_with(successful_authorize_response) + + assert_success response end def test_purchase_with_credit_card - @gateway.expects(:ssl_request). - with( - :post, - 'https://api.sandbox.datatrans.com/v1/transactions/authorize', - all_of( - # same than authorize + autoSettle value - includes('"autoSettle":true') - ), - anything - ). - returns(successful_authorize_response) - - @gateway.purchase(@amount, @credit_card, @options) + response = stub_comms(@gateway, :ssl_request) do + @gateway.purchase(@amount, @credit_card, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('authorize', endpoint) + assert_match(/\"number\":\"(\d+{12})\"/, data) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + assert_match('"autoSettle":true', data) + end.respond_with(successful_purchase_response) + + assert_success response + end + + def test_purchase_with_network_token + response = stub_comms(@gateway, :ssl_request) do + @gateway.purchase(@amount, @nt_credit_card, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('authorize', endpoint) + assert_match(/\"token\":\"(\d+{12})\"/, data) + assert_match(/\"type\":\"NETWORK_TOKEN"/, data) + assert_match(/\"tokenType\":\"VISA"/, data) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + assert_match('"autoSettle":true', data) + end.respond_with(successful_purchase_response) + + assert_success response + end + + def test_authorize_with_apple_pay + response = stub_comms(@gateway, :ssl_request) do + @gateway.purchase(@amount, @apple_pay_card, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('authorize', endpoint) + assert_match(/\"token\":\"(\d+{12})\"/, data) + assert_match(/\"type\":\"DEVICE_TOKEN"/, data) + assert_match(/\"tokenType\":\"APPLE_PAY"/, data) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + end.respond_with(successful_purchase_response) + + assert_success response end def test_capture - @gateway.expects(:ssl_request).with(:post, 'https://api.sandbox.datatrans.com/v1/transactions/authorize', anything, anything).returns(successful_authorize_response) - - authorize_response = @gateway.authorize(@amount, @credit_card, @options) - transaction_reference, _card_token, _brand = authorize_response.authorization.split('|') - @gateway.expects(:ssl_request). - with( - :post, - regexp_matches(%r{https://api.sandbox.datatrans.com/v1/transactions/(\d+)/settle}), - all_of( - regexp_matches(%r{"refno\":\"(\d+)\"}), - includes('"currency":"CHF"'), - includes('"amount":"100"') - ), - anything - ). - returns(successful_capture_response) - @gateway.capture(@amount, transaction_reference, @options) + response = stub_comms(@gateway, :ssl_request) do + @gateway.capture(@amount, @transaction_reference, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('240214093712238757/settle', endpoint) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + end.respond_with(successful_capture_response) + + assert_success response end def test_refund - @gateway.expects(:ssl_request).with(:post, 'https://api.sandbox.datatrans.com/v1/transactions/authorize', anything, anything).returns(successful_purchase_response) - - purchase_response = @gateway.purchase(@amount, @credit_card, @options) - transaction_reference, _card_token, _brand = purchase_response.authorization.split('|') - @gateway.expects(:ssl_request). - with( - :post, - regexp_matches(%r{https://api.sandbox.datatrans.com/v1/transactions/(\d+)/credit}), - all_of( - regexp_matches(%r{"refno\":\"(\d+)\"}), - includes('"currency":"CHF"'), - includes('"amount":"100"') - ), - anything - ). - returns(successful_refund_response) - @gateway.refund(@amount, transaction_reference, @options) + response = stub_comms(@gateway, :ssl_request) do + @gateway.refund(@amount, @transaction_reference, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('240214093712238757/credit', endpoint) + assert_match(/\"refno\":(\d+)/, data) + assert_match('"currency":"CHF"', data) + assert_match('"amount":"100"', data) + end.respond_with(successful_refund_response) + + assert_success response end - def test_void - @gateway.expects(:ssl_request).with(:post, 'https://api.sandbox.datatrans.com/v1/transactions/authorize', anything, anything).returns(successful_purchase_response) - - authorize_response = @gateway.authorize(@amount, @credit_card, @options) - transaction_reference, _card_token, _brand = authorize_response.authorization.split('|') - @gateway.expects(:ssl_request). - with( - :post, - regexp_matches(%r{https://api.sandbox.datatrans.com/v1/transactions/(\d+)/cancel}), - '{}', - anything - ). - returns(successful_void_response) - @gateway.void(transaction_reference, @options) + def test_voids + response = stub_comms(@gateway, :ssl_request) do + @gateway.void(@transaction_reference, @options) + end.check_request do |_action, endpoint, data, _headers| + assert_match('240214093712238757/cancel', endpoint) + assert_equal data, '{}' + end.respond_with(successful_void_response) + + assert_success response end def test_required_merchant_id_and_password @@ -197,6 +220,25 @@ def test_scrub assert_equal post_scrubbed, @gateway.scrub(pre_scrubbed) end + def test_authorization_from + assert_equal '1234|9248', @gateway.send(:authorization_from, { 'transactionId' => '1234', 'acquirerAuthorizationCode' => '9248' }) + assert_equal '1234|', @gateway.send(:authorization_from, { 'transactionId' => '1234' }) + assert_equal '|9248', @gateway.send(:authorization_from, { 'acquirerAuthorizationCode' => '9248' }) + assert_equal nil, @gateway.send(:authorization_from, {}) + end + + def test_parse + assert_equal @gateway.send(:parse, '{"response_code":204}'), { 'response_code' => 204 } + assert_equal @gateway.send(:parse, '{"transactionId":"240418170233899207","acquirerAuthorizationCode":"170233"}'), { 'transactionId' => '240418170233899207', 'acquirerAuthorizationCode' => '170233' } + + assert_equal @gateway.send(:parse, + '{"transactionId":"240418170233899207",acquirerAuthorizationCode":"170233"}'), + { 'successful' => false, + 'response' => {}, + 'errors' => + ['Invalid JSON response received from Datatrans. Please contact them for support if you continue to receive this message. (The raw response returned by the API was "{\\"transactionId\\":\\"240418170233899207\\",acquirerAuthorizationCode\\":\\"170233\\"}")'] } + end + private def successful_authorize_response @@ -206,22 +248,13 @@ def successful_authorize_response }' end - def successful_purchase_response - successful_authorize_response - end - def successful_capture_response '{"response_code": 204}' end - def successful_refund_response - successful_authorize_response - end - - def successful_void_response - successful_capture_response - end - + alias successful_purchase_response successful_authorize_response + alias successful_refund_response successful_authorize_response + alias successful_void_response successful_capture_response def pre_scrubbed <<~PRE_SCRUBBED @@ -251,7 +284,7 @@ def pre_scrubbed -> \"Correlation-Id: abda35b0-44ac-4a42-8811-941488acc21b\\r\\n\"\n -> \"\\r\\n\"\nreading 86 bytes...\n -> \"{\\n - \\\"transactionId\\\" : \\\"240418170233899207\\\",\\n#{' '} + \\\"transactionId\\\" : \\\"240418170233899207\\\",\\n \\\"acquirerAuthorizationCode\\\" : \\\"170233\\\"\\n }\"\n read 86 bytes\n @@ -287,7 +320,7 @@ def post_scrubbed -> \"Correlation-Id: abda35b0-44ac-4a42-8811-941488acc21b\\r\\n\"\n -> \"\\r\\n\"\nreading 86 bytes...\n -> \"{\\n - \\\"transactionId\\\" : \\\"240418170233899207\\\",\\n#{' '} + \\\"transactionId\\\" : \\\"240418170233899207\\\",\\n \\\"acquirerAuthorizationCode\\\" : \\\"170233\\\"\\n }\"\n read 86 bytes\n