From 50f3987f70a49371872f0ae268b8bf0ba9a1e6bd Mon Sep 17 00:00:00 2001 From: cristian Date: Fri, 15 Nov 2024 17:52:52 -0500 Subject: [PATCH] Fortis: Initial implementation Summary: ------------------------------ Fortis Gateway intial implementation with support for: - Authorize - Purchase - Void - Capture - Refund - Credit - Store - Unstore Remote Test: ------------------------------ Finished in 114.375184 seconds. 22 tests, 53 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed 0.19 tests/s, 0.46 assertions/s Unit Tests: ------------------------------ Finished in 68.455283 seconds. 6138 tests, 80277 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed RuboCop: ------------------------------ 807 files inspected, no offenses detected --- Rakefile | 1 + .../billing/gateways/fortis.rb | 331 ++++++++++++++++++ lib/active_merchant/connection.rb | 2 +- .../net_http_ssl_connection.rb | 35 ++ lib/active_merchant/posts_data.rb | 11 + test/fixtures.yml | 6 + test/remote/gateways/remote_fortis_test.rb | 191 ++++++++++ test/unit/gateways/fortis_test.rb | 292 +++++++++++++++ 8 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 lib/active_merchant/billing/gateways/fortis.rb create mode 100644 test/remote/gateways/remote_fortis_test.rb create mode 100644 test/unit/gateways/fortis_test.rb diff --git a/Rakefile b/Rakefile index 0334d839734..2a4b5d39097 100644 --- a/Rakefile +++ b/Rakefile @@ -31,6 +31,7 @@ RuboCop::RakeTask.new namespace :test do Rake::TestTask.new(:units) do |t| + ENV['RUNNING_UNIT_TESTS'] = 'true' t.pattern = 'test/unit/**/*_test.rb' t.libs << 'test' t.verbose = false diff --git a/lib/active_merchant/billing/gateways/fortis.rb b/lib/active_merchant/billing/gateways/fortis.rb new file mode 100644 index 00000000000..c283ef70a7f --- /dev/null +++ b/lib/active_merchant/billing/gateways/fortis.rb @@ -0,0 +1,331 @@ +module ActiveMerchant # :nodoc: + module Billing # :nodoc: + class FortisGateway < Gateway + self.test_url = 'https://api.sandbox.fortis.tech/v1' + self.live_url = 'https://api.fortis.tech/v1' + + self.supported_countries = %w{US CA} + self.default_currency = 'USD' + self.supported_cardtypes = %i[visa master american_express discover jbc unionpay] + self.money_format = :cents + self.homepage_url = 'https://fortispay.com' + self.display_name = 'Fortis' + + STATUS_MAPPING = { + 101 => 'Sale cc Approved', + 102 => 'Sale cc AuthOnly', + 111 => 'Refund cc Refunded', + 121 => 'Credit/Debit/Refund cc AvsOnly', + 131 => 'Credit/Debit/Refund ach Pending Origination', + 132 => 'Credit/Debit/Refund ach Originating', + 133 => 'Credit/Debit/Refund ach Originated', + 134 => 'Credit/Debit/Refund ach Settled', + 191 => 'Settled (deprecated - batches are now settled on the /v2/transactionbatches endpoint)', + 201 => 'All cc/ach Voided', + 301 => 'All cc/ach Declined', + 331 => 'Credit/Debit/Refund ach Charged Back' + } + + REASON_MAPPING = { + 0 => 'N/A', + 1000 => 'CC - Approved / ACH - Accepted', + 1001 => 'AuthCompleted', + 1002 => 'Forced', + 1003 => 'AuthOnly Declined', + 1004 => 'Validation Failure (System Run Trx)', + 1005 => 'Processor Response Invalid', + 1200 => 'Voided', + 1201 => 'Partial Approval', + 1240 => 'Approved, optional fields are missing (Paya ACH only)', + 1301 => 'Account Deactivated for Fraud', + 1500 => 'Generic Decline', + 1510 => 'Call', + 1518 => 'Transaction Not Permitted - Terminal', + 1520 => 'Pickup Card', + 1530 => 'Retry Trx', + 1531 => 'Communication Error', + 1540 => 'Setup Issue, contact Support', + 1541 => 'Device is not signature capable', + 1588 => 'Data could not be de-tokenized', + 1599 => 'Other Reason', + 1601 => 'Generic Decline', + 1602 => 'Call', + 1603 => 'No Reply', + 1604 => 'Pickup Card - No Fraud', + 1605 => 'Pickup Card - Fraud', + 1606 => 'Pickup Card - Lost', + 1607 => 'Pickup Card - Stolen', + 1608 => 'Account Error', + 1609 => 'Already Reversed', + 1610 => 'Bad PIN', + 1611 => 'Cashback Exceeded', + 1612 => 'Cashback Not Available', + 1613 => 'CID Error', + 1614 => 'Date Error', + 1615 => 'Do Not Honor', + 1616 => 'NSF', + 1618 => 'Invalid Service Code', + 1619 => 'Exceeded activity limit', + 1620 => 'Violation', + 1621 => 'Encryption Error', + 1622 => 'Card Expired', + 1623 => 'Renter', + 1624 => 'Security Violation', + 1625 => 'Card Not Permitted', + 1626 => 'Trans Not Permitted', + 1627 => 'System Error', + 1628 => 'Bad Merchant ID', + 1629 => 'Duplicate Batch (Already Closed)', + 1630 => 'Batch Rejected', + 1631 => 'Account Closed', + 1632 => 'PIN tries exceeded', + 1640 => 'Required fields are missing (ACH only)', + 1641 => 'Previously declined transaction (1640)', + 1650 => 'Contact Support', + 1651 => 'Max Sending - Throttle Limit Hit (ACH only)', + 1652 => 'Max Attempts Exceeded', + 1653 => 'Contact Support', + 1654 => 'Voided - Online Reversal Failed', + 1655 => 'Decline (AVS Auto Reversal)', + 1656 => 'Decline (CVV Auto Reversal)', + 1657 => 'Decline (Partial Auth Auto Reversal)', + 1658 => 'Expired Authorization', + 1659 => 'Declined - Partial Approval not Supported', + 1660 => 'Bank Account Error, please delete and re-add Token', + 1661 => 'Declined AuthIncrement', + 1662 => 'Auto Reversal - Processor cant settle', + 1663 => 'Manager Needed (Needs override transaction)', + 1664 => 'Token Not Found: Sharing Group Unavailable', + 1665 => 'Contact Not Found: Sharing Group Unavailable', + 1666 => 'Amount Error', + 1667 => 'Action Not Allowed in Current State', + 1668 => 'Original Authorization Not Valid', + 1701 => 'Chip Reject', + 1800 => 'Incorrect CVV', + 1801 => 'Duplicate Transaction', + 1802 => 'MID/TID Not Registered', + 1803 => 'Stop Recurring', + 1804 => 'No Transactions in Batch', + 1805 => 'Batch Does Not Exist' + } + + def initialize(options = {}) + requires!(options, :user_id, :user_api_key, :developer_id) + super + end + + def authorize(money, payment, options = {}) + commit path(:authorize, payment_type(payment)), auth_purchase_request(money, payment, options) + end + + def purchase(money, payment, options = {}) + commit path(:purchase, payment_type(payment)), auth_purchase_request(money, payment, options) + end + + def capture(money, authorization, options = {}) + commit path(:capture, authorization), { transaction_amount: money }, :patch + end + + def void(authorization, options = {}) + commit path(:void, authorization), {}, :put + end + + def refund(money, authorization, options = {}) + commit path(:refund, authorization), { transaction_amount: money }, :patch + end + + def credit(money, payment, options = {}) + commit path(:credit), auth_purchase_request(money, payment, options) + end + + def store(payment, options = {}) + post = {} + add_payment(post, payment, include_cvv: false) + add_address(post, payment, options) + + commit path(:store), post + end + + def unstore(authorization, options = {}) + commit path(:unstore, authorization), nil, :delete + end + + def supports_scrubbing? + true + end + + def scrub(transcript) + transcript. + gsub(/(\\?"account_number\\?":\\?")\d+/, '\1[FILTERED]'). + gsub(/(\\?"cvv\\?":\\?")\d+/, '\1[FILTERED]'). + gsub(%r((user-id: )[\w =]+), '\1[FILTERED]'). + gsub(%r((user-api-key: )[\w =]+), '\1[FILTERED]'). + gsub(%r((developer-id: )[\w =]+), '\1[FILTERED]') + end + + private + + def path(action, value = '') + { + authorize: '/transactions/cc/auth-only/{placeholder}', + purchase: '/transactions/cc/sale/{placeholder}', + capture: '/transactions/{placeholder}/auth-complete', + void: '/transactions/{placeholder}/void', + refund: '/transactions/{placeholder}/refund', + credit: '/transactions/cc/refund/keyed', + store: '/tokens/cc', + unstore: '/tokens/{placeholder}' + }[action]&.gsub('{placeholder}', value.to_s) + end + + def payment_type(payment) + payment.is_a?(String) ? 'token' : 'keyed' + end + + def auth_purchase_request(money, payment, options = {}) + {}.tap do |post| + add_invoice(post, money, options) + add_payment(post, payment) + add_address(post, payment, options) + end + end + + def add_address(post, payment_method, options) + address = address_from_options(options) + return unless address.present? + + post[:billing_address] = { + postal_code: address[:zip], + street: address[:address1], + city: address[:city], + state: address[:state], + phone: address[:phone], + country: lookup_country_code(address[:country]) + }.compact + end + + def address_from_options(options) + options[:billing_address] || options[:address] || {} + end + + def lookup_country_code(country_field) + return unless country_field.present? + + country_code = Country.find(country_field) + country_code&.code(:alpha3)&.value + end + + def add_invoice(post, money, options) + post[:transaction_amount] = amount(money) + post[:order_number] = options[:order_id] + post[:transaction_api_id] = options[:order_id] + post[:notification_email_address] = options[:email] + end + + def add_payment(post, payment, include_cvv: true) + case payment + when CreditCard + post[:account_number] = payment.number + post[:exp_date] = expdate(payment) + post[:cvv] = payment.verification_value if include_cvv + post[:account_holder_name] = payment.name + when String + post[:token_id] = payment + end + end + + def parse(body) + JSON.parse(body).with_indifferent_access + rescue JSON::ParserError, TypeError => e + { + errors: body, + status: 'Unable to parse JSON response', + message: e.message + }.with_indifferent_access + end + + def request_headers + CaseSensitiveHeaders.new.reverse_merge!({ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'user-id' => @options[:user_id], + 'user-api-key' => @options[:user_api_key], + 'developer-id' => @options[:developer_id] + }) + end + + def add_location_id(post, options) + post[:location_id] = @options[:location_id] || options[:location_id] + end + + def commit(path, post, method = :post, options = {}) + add_location_id(post, options) if post.present? + + http_code, raw_response = ssl_request(method, url(path), post&.compact&.to_json, request_headers) + response = parse(raw_response) + + Response.new( + success_from(http_code, response), + message_from(response), + response, + authorization: authorization_from(response), + avs_result: AVSResult.new(code: response.dig(:data, :avs_enhanced)), + cvv_result: CVVResult.new(response.dig(:data, :cvv_response)), + test: test?, + error_code: error_code_from(http_code, response) + ) + rescue ResponseError => e + response = parse(e.response.body) + Response.new(false, message_from(response), response, test: test?) + end + + def handle_response(response) + case response.code.to_i + when 200...300 + return response.code.to_i, response.body + else + raise ResponseError.new(response) + end + end + + def url(path) + "#{test? ? test_url : live_url}#{path}" + end + + def success_from(http_code, response) + return true if http_code == 204 + return response[:data][:active] == true if response[:type] == 'Token' + return false if response.dig(:data, :status_code) == 301 + + STATUS_MAPPING[response.dig(:data, :status_code)].present? + end + + def message_from(response) + return '' if response.blank? + + response[:type] == 'Error' ? error_message_from(response) : success_message_from(response) + end + + def error_message_from(response) + response[:detail] || response[:title] + end + + def success_message_from(response) + response.dig(:data, :verbiaje) || get_reason_description_from(response) || STATUS_MAPPING[response.dig(:data, :status_code)] || response.dig(:data, :status_code) + end + + def get_reason_description_from(response) + code_id = response.dig(:data, :reason_code_id) + REASON_MAPPING[code_id] || ((1302..1399).include?(code_id) ? 'Reserved for Future Fraud Reason Codes' : nil) + end + + def authorization_from(response) + response.dig(:data, :id) + end + + def error_code_from(http_code, response) + [response.dig(:data, :status_code), response.dig(:data, :reason_code_id)].compact.join(' - ') unless success_from(http_code, response) + end + end + end +end diff --git a/lib/active_merchant/connection.rb b/lib/active_merchant/connection.rb index ab2d43e0be9..024f833a84f 100644 --- a/lib/active_merchant/connection.rb +++ b/lib/active_merchant/connection.rb @@ -76,7 +76,7 @@ def request(method, body, headers = {}) http.get(endpoint.request_uri, headers) when :post debug body - http.post(endpoint.request_uri, body, RUBY_184_POST_HEADERS.merge(headers)) + http.post(endpoint.request_uri, body, headers.reverse_merge!(RUBY_184_POST_HEADERS)) when :put debug body http.put(endpoint.request_uri, body, headers) diff --git a/lib/active_merchant/net_http_ssl_connection.rb b/lib/active_merchant/net_http_ssl_connection.rb index 17babbf0510..00f8df73699 100644 --- a/lib/active_merchant/net_http_ssl_connection.rb +++ b/lib/active_merchant/net_http_ssl_connection.rb @@ -1,11 +1,46 @@ require 'net/http' module NetHttpSslConnection + module InnocuousCapitalize + def capitalize(name) = name + private :capitalize + end + + class CaseSensitivePost < Net::HTTP::Post; prepend InnocuousCapitalize; end + + class CaseSensitivePatch < Net::HTTP::Patch; prepend InnocuousCapitalize; end + + class CaseSensitivePut < Net::HTTP::Put; prepend InnocuousCapitalize; end + + class CaseSensitiveDelete < Net::HTTP::Delete; prepend InnocuousCapitalize; end + refine Net::HTTP do def ssl_connection return {} unless use_ssl? && @socket.present? { version: @socket.io.ssl_version, cipher: @socket.io.cipher[0] } end + + unless ENV['RUNNING_UNIT_TESTS'] + def post(path, data, initheader = nil, dest = nil, &block) + send_entity(path, data, initheader, dest, request_type(CaseSensitivePost, Net::HTTP::Post, initheader), &block) + end + + def patch(path, data, initheader = nil, dest = nil, &block) + send_entity(path, data, initheader, dest, request_type(CaseSensitivePatch, Net::HTTP::Patch, initheader), &block) + end + + def put(path, data, initheader = nil) + request(request_type(CaseSensitivePut, Net::HTTP::Put, initheader).new(path, initheader), data) + end + + def delete(path, initheader = { 'Depth' => 'Infinity' }) + request(request_type(CaseSensitiveDelete, Net::HTTP::Delete, initheader).new(path, initheader)) + end + + def request_type(replace, default, initheader) + initheader.is_a?(ActiveMerchant::PostsData::CaseSensitiveHeaders) ? replace : default + end + end end end diff --git a/lib/active_merchant/posts_data.rb b/lib/active_merchant/posts_data.rb index ab82e6a4770..581a4317003 100644 --- a/lib/active_merchant/posts_data.rb +++ b/lib/active_merchant/posts_data.rb @@ -92,5 +92,16 @@ def handle_response(response) raise ResponseError.new(response) end end + + # This class is needed to play along with the Refinement done for Net::HTTP + # class so it can have a way to detect if the hash that represent the headers + # should use the case sensitive version of the headers or not. + class CaseSensitiveHeaders < Hash + def dup + case_sensitive_dup = self.class.new + each { |key, value| case_sensitive_dup[key] = value } + case_sensitive_dup + end + end end end diff --git a/test/fixtures.yml b/test/fixtures.yml index 39fa2c9eb7a..0183c360730 100644 --- a/test/fixtures.yml +++ b/test/fixtures.yml @@ -452,6 +452,12 @@ forte: api_key: "f087a90f00f0ae57050c937ed3815c9f" secret: "d793d64064e3113a74fa72035cfc3a1d" +fortis: + user_id: 'USER_ID' + user_api_key: 'USER_API_KEY' + developer_id: 'DEVELOPER_ID' + location_id: 'LOCATION_ID' + garanti: login: "PROVAUT" terminal_id: 30691300 diff --git a/test/remote/gateways/remote_fortis_test.rb b/test/remote/gateways/remote_fortis_test.rb new file mode 100644 index 00000000000..600ee7f92a0 --- /dev/null +++ b/test/remote/gateways/remote_fortis_test.rb @@ -0,0 +1,191 @@ +require 'test_helper' + +class RemoteFortisTest < Test::Unit::TestCase + def setup + @gateway = FortisGateway.new(fixtures(:fortis)) + @amount = 100 + @credit_card = credit_card('5454545454545454', verification_value: '999') + @incomplete_credit_card = credit_card('54545454545454') + @billing_address = { + name: 'John Doe', + address1: '1 Market St', + city: 'san francisco', + state: 'CA', + zip: '94105', + country: 'US', + phone: '4158880000' + } + @options = { + order_id: generate_unique_id, + currency: 'USD', + email: 'test@cybs.com' + } + @complete_options = { + billing_address: @address, + description: 'Store Purchase' + } + end + + def test_invalid_login + gateway = FortisGateway.new( + user_id: 'abc123', + user_api_key: 'abc123', + developer_id: 'abc123', + location_id: @gateway.options[:location_id] + ) + + response = gateway.purchase(@amount, @credit_card, @options) + assert_failure response + assert_match 'Unauthorized', response.message + end + + def test_successful_authorize + response = @gateway.authorize(@amount, @credit_card, @options) + assert_success response + assert_equal 'CC - Approved / ACH - Accepted', response.message + assert_equal 'Y', response.avs_result['postal_match'] + assert_equal 'Y', response.avs_result['street_match'] + end + + def test_successful_purchase + response = @gateway.purchase(@amount, @credit_card, @options) + assert_success response + assert_equal 'CC - Approved / ACH - Accepted', response.message + end + + def test_successful_authorize_and_capture + auth = @gateway.authorize(@amount, @credit_card, @options) + assert_success auth + + assert capture = @gateway.capture(@amount, auth.authorization) + assert_success capture + assert_equal 'AuthCompleted', capture.message + end + + def test_failed_capture + response = @gateway.capture(@amount, 'abc123') + assert_failure response + assert_match %r{"transaction_id" with value "abc123" fails to match the Fortis}, response.message + end + + def test_partial_capture + auth = @gateway.authorize(1000, @credit_card, @options) + assert_success auth + + assert capture = @gateway.capture(800, auth.authorization) + assert_success capture + end + + def test_failed_authorize_declined + response = @gateway.authorize(622, @credit_card, @options) + assert_failure response + assert_equal 'Card Expired', response.message + end + + def test_failed_authorize_generic_fail + response = @gateway.authorize(601, @credit_card, @options) + assert_failure response + assert_equal 'Generic Decline', response.message + end + + def test_failed_purchase + response = @gateway.purchase(622, @credit_card, @options) + assert_failure response + assert_equal 'Card Expired', response.message + end + + def test_successful_purchase_with_more_options + response = @gateway.purchase(@amount, @credit_card, @complete_options) + assert_success response + assert_equal 'CC - Approved / ACH - Accepted', response.message + end + + def test_successful_void + auth = @gateway.authorize(@amount, @credit_card, @options) + assert_success auth + + assert void = @gateway.void(auth.authorization) + assert_success void + assert_equal 'Voided', void.message + end + + def test_failed_void + response = @gateway.void('abc123') + assert_failure response + assert_match %r{"transaction_id" with value "abc123" fails to match the Fortis}, 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 'CC - Approved / ACH - Accepted', refund.message + end + + def test_partial_refund + purchase = @gateway.purchase(1000, @credit_card, @options) + assert_success purchase + + assert refund = @gateway.refund(800, purchase.authorization) + assert_success refund + end + + def test_failed_refund + response = @gateway.refund(@amount, 'abc123') + assert_failure response + assert_match %r{"transaction_id" with value "abc123" fails to match the Fortis}, response.message + end + + def test_successful_credit + response = @gateway.credit(@amount, @credit_card, @complete_options) + assert_success response + assert_equal 'CC - Approved / ACH - Accepted', response.message + end + + def test_failed_credit + response = @gateway.credit(622, @credit_card, @options) + assert_failure response + assert_equal 'Card Expired', response.message + end + + def test_storing_credit_card + store = @gateway.store(@credit_card, @options) + assert_success store + end + + def test_failded_store_credit_card + response = @gateway.store(@incomplete_credit_card, @options) + assert_failure response + assert_equal '"account_number" must be a credit card', response.message + end + + def test_authorize_with_stored_credit_card + store = @gateway.store(@credit_card, @options) + assert_success store + + response = @gateway.authorize(@amount, store.authorization, @options) + assert_success response + assert_equal 'CC - Approved / ACH - Accepted', response.message + end + + def test_unstore + store = @gateway.store(@credit_card, @options) + assert_success store + + unstore = @gateway.unstore(store.authorization) + assert_success unstore + end + + def test_transcript_scrubbing + transcript = capture_transcript(@gateway) do + @gateway.authorize(@amount, @credit_card, @options) + end + transcript = @gateway.scrub(transcript) + + assert_scrubbed(@credit_card.number, transcript) + assert_scrubbed(@gateway.options[:user_api_key], transcript) + assert_scrubbed(@gateway.options[:developer_id], transcript) + end +end diff --git a/test/unit/gateways/fortis_test.rb b/test/unit/gateways/fortis_test.rb new file mode 100644 index 00000000000..46d700e505d --- /dev/null +++ b/test/unit/gateways/fortis_test.rb @@ -0,0 +1,292 @@ +require 'test_helper' + +class FortisTest < Test::Unit::TestCase + include CommStub + + def setup + @gateway = FortisGateway.new(user_id: 'abc', user_api_key: 'def', developer_id: 'ghi', location_id: 'jkl') + @credit_card = credit_card + @amount = 100 + + @options = { + order_id: '1', + billing_address: address, + description: 'Store Purchase' + } + end + + def test_raises_error_without_required_options + assert_raises(ArgumentError) { FortisGateway.new } + assert_raises(ArgumentError) { FortisGateway.new(user_id: 'abc') } + assert_raises(ArgumentError) { FortisGateway.new(user_id: 'abc', user_api_key: 'def') } + assert_nothing_raised { FortisGateway.new(user_id: 'abc', user_api_key: 'def', developer_id: 'ghi') } + end + + def test_parse_valid_json + body = '{"key": "value"}' + expected_result = { 'key' => 'value' }.with_indifferent_access + result = @gateway.send(:parse, body) + assert_equal expected_result, result + end + + def test_parse_invalid_json + body = 'invalid json' + result = @gateway.send(:parse, body) + assert_equal 'Unable to parse JSON response', result[:status] + assert_equal body, result[:errors] + assert result[:message].include?('unexpected token') + end + + def test_parse_empty_json + body = '' + result = @gateway.send(:parse, body) + assert_equal 'Unable to parse JSON response', result[:status] + assert_equal body, result[:errors] + assert result[:message].include?('unexpected token') + end + + def test_request_headers + expected = { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'user-id' => 'abc', + 'user-api-key' => 'def', + 'developer-id' => 'ghi' + } + assert_equal expected, @gateway.send(:request_headers) + end + + def test_url_for_test_environment + @gateway.stubs(:test?).returns(true) + assert_equal 'https://api.sandbox.fortis.tech/v1/some_action', @gateway.send(:url, '/some_action') + end + + def test_url_for_live_environment + @gateway.stubs(:test?).returns(false) + assert_equal 'https://api.fortis.tech/v1/some_action', @gateway.send(:url, '/some_action') + end + + def test_success_from + assert @gateway.send(:success_from, 200, { data: { status_code: 101 } }) + refute @gateway.send(:success_from, 200, { data: { status_code: 301 } }) + refute @gateway.send(:success_from, 200, { data: { status_code: 999 } }) + end + + def test_message_from + assert_equal 'Transaction Approved', @gateway.send(:message_from, { data: { verbiaje: 'Transaction Approved' } }) + assert_equal 'CC - Approved / ACH - Accepted', @gateway.send(:message_from, { data: { reason_code_id: 1000 } }) + assert_equal 'Sale cc Approved', @gateway.send(:message_from, { data: { status_code: 101 } }) + assert_equal 'Reserved for Future Fraud Reason Codes', @gateway.send(:message_from, { data: { reason_code_id: 1302 } }) + assert_equal 999, @gateway.send(:message_from, { data: { status_code: 999 } }) + end + + def test_get_reason_description_from + assert_equal 'CC - Approved / ACH - Accepted', @gateway.send(:get_reason_description_from, { data: { reason_code_id: 1000 } }) + assert_equal 'Reserved for Future Fraud Reason Codes', @gateway.send(:get_reason_description_from, { data: { reason_code_id: 1302 } }) + assert_nil @gateway.send(:get_reason_description_from, { data: { reason_code_id: 9999 } }) + end + + def test_authorization_from + assert_equal '31efa3732483237895c9a23d', @gateway.send(:authorization_from, { data: { id: '31efa3732483237895c9a23d' } }) + assert_nil @gateway.send(:authorization_from, { data: { id: nil } }) + assert_nil @gateway.send(:authorization_from, { data: {} }) + assert_nil @gateway.send(:authorization_from, {}) + end + + def test_successfully_build_an_authorize_request + stub_comms(@gateway, :ssl_request) do + @gateway.authorize(699, @credit_card, @options) + end.check_request do |_method, _endpoint, data, _headers| + request = JSON.parse(data) + assert_equal '699', request['transaction_amount'] + assert_equal @options[:order_id], request['order_number'] + assert_equal @options[:order_id], request['transaction_api_id'] + assert_equal @credit_card.number, request['account_number'] + assert_equal @credit_card.month.to_s.rjust(2, '0') + @credit_card.year.to_s[-2..-1], request['exp_date'] + assert_equal @credit_card.verification_value, request['cvv'] + assert_equal @credit_card.name, request['account_holder_name'] + assert_equal @options[:billing_address][:address1], request['billing_address']['street'] + assert_equal @options[:billing_address][:city], request['billing_address']['city'] + assert_equal @options[:billing_address][:state], request['billing_address']['state'] + assert_equal @options[:billing_address][:zip], request['billing_address']['postal_code'] + assert_equal 'CAN', request['billing_address']['country'] + end.respond_with(successful_authorize_response) + end + + def test_on_purchase_point_to_the_sale_endpoint + stub_comms(@gateway, :ssl_request) do + @gateway.purchase(699, @credit_card, @options) + end.check_request do |_method, endpoint, _data, _headers| + assert_match %r{sale}, endpoint + end.respond_with(successful_authorize_response) + end + + def test_scrub + assert @gateway.supports_scrubbing? + assert_equal @gateway.scrub(pre_scrubbed), post_scrubbed + end + + def test_error_code_from_with_status_code_301 + response = { data: { status_code: 301, reason_code_id: 1622 } } + assert_equal '301 - 1622', @gateway.send(:error_code_from, 400, response) + end + + def test_error_code_from_with_status_code_101 + response = { data: { status_code: 101 } } + assert_nil @gateway.send(:error_code_from, 200, response) + end + + def test_error_code_from_with_status_code_500 + response = { data: { status_code: 500 } } + assert_equal '500', @gateway.send(:error_code_from, 500, response) + end + + def test_error_code_from_with_nil_status_code + response = { data: { status_code: nil } } + assert_nil @gateway.send(:error_code_from, 204, response) + end + + private + + def pre_scrubbed + <<~PRE + <- "POST /v1/transactions/cc/auth-only/keyed HTTP/1.1\r\ncontent-type: application/json\r\naccept: application/json\r\nuser-id: 11ef69fdc8fd8db2b07213de\r\nuser-api-key: 11ef9c5897f42ac2a072e521\r\ndeveloper-id: bEgKPZos\r\nconnection: close\r\naccept-encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nuser-agent: Ruby\r\nhost: api.sandbox.fortis.tech\r\ncontent-length: 154\r\n\r\n" + <- "{\"transaction_amount\":\"100\",\"order_number\":null,\"account_number\":\"5454545454545454\",\"exp_date\":\"0925\",\"cvv\":\"123\",\"account_holder_name\":\"Longbob Longsen\"}" + -> "HTTP/1.1 201 Created\r\n" + -> "Date: Fri, 15 Nov 2024 17:00:42 GMT\r\n" + -> "Content-Type: application/json\r\n" + -> "Content-Length: 2031\r\n" + -> "Connection: close\r\n" + -> "x-amzn-RequestId: dfd852a8-5c39-4558-9f05-8eaa14affac9\r\n" + -> "Access-Control-Allow-Origin: *\r\n" + -> "x-amz-apigw-id: BTCoUG6SIAMEsdw=\r\n" + -> "X-Amzn-Trace-Id: Root=1-67377e34-170c289b473cbb2e56aeab61;Parent=7e76c3e737bb48c3;Sampled=0;Lineage=1:ae593ade:0\r\n" + -> "Access-Control-Max-Age: 86400\r\n" + -> "Access-Control-Allow-Credentials: true\r\n" + -> "\r\n" + reading 2031 bytes... + -> "{\"type\":\"Transaction\",\"data\":{\"id\":\"31efa3732483237895c9a23d\",\"payment_method\":\"cc\",\"account_vault_id\":null,\"recurring_id\":null,\"first_six\":\"545454\",\"last_four\":\"5454\",\"account_holder_name\":\"Longbob Longsen\",\"transaction_amount\":100,\"description\":null,\"transaction_code\":null,\"avs\":null,\"batch\":null,\"verbiage\":\"Test 7957\",\"transaction_settlement_status\":null,\"effective_date\":null,\"return_date\":null,\"created_ts\":1731690040,\"modified_ts\":1731690040,\"transaction_api_id\":null,\"terms_agree\":null,\"notification_email_address\":null,\"notification_email_sent\":true,\"notification_phone\":null,\"response_message\":null,\"auth_amount\":100,\"auth_code\":\"a37325\",\"type_id\":20,\"location_id\":\"11ef69fdc684ae30b436c55b\",\"reason_code_id\":1000,\"contact_id\":null,\"product_transaction_id\":\"11ef69fdc6debc2cb1af505c\",\"tax\":0,\"customer_ip\":\"34.234.17.123\",\"customer_id\":null,\"po_number\":null,\"avs_enhanced\":\"V\",\"cvv_response\":\"N\",\"cavv_result\":null,\"clerk_number\":null,\"tip_amount\":0,\"created_user_id\":\"11ef69fdc8fd8db2b07213de\",\"modified_user_id\":\"11ef69fdc8fd8db2b07213de\",\"ach_identifier\":null,\"check_number\":null,\"recurring_flag\":\"no\",\"installment_counter\":null,\"installment_total\":null,\"settle_date\":null,\"charge_back_date\":null,\"void_date\":null,\"account_type\":\"mc\",\"is_recurring\":false,\"is_accountvault\":false,\"transaction_c1\":null,\"transaction_c2\":null,\"transaction_c3\":null,\"additional_amounts\":[],\"terminal_serial_number\":null,\"entry_mode_id\":\"K\",\"terminal_id\":null,\"quick_invoice_id\":null,\"ach_sec_code\":null,\"custom_data\":null,\"ebt_type\":null,\"voucher_number\":null,\"hosted_payment_page_id\":null,\"transaction_batch_id\":null,\"currency_code\":840,\"par\":\"ZZZZZZZZZZZZZZZZZZZZ545454545\",\"stan\":null,\"currency\":\"USD\",\"secondary_amount\":0,\"card_bin\":\"545454\",\"paylink_id\":null,\"emv_receipt_data\":null,\"status_code\":102,\"token_id\":null,\"wallet_type\":null,\"order_number\":\"963274518498\",\"routing_number\":null,\"trx_source_code\":12,\"billing_address\":{\"city\":null,\"state\":null,\"postal_code\":null,\"phone\":null,\"country\":null,\"street\":null},\"is_token\":false}}" + PRE + end + + def post_scrubbed + <<~PRE + <- "POST /v1/transactions/cc/auth-only/keyed HTTP/1.1\r\ncontent-type: application/json\r\naccept: application/json\r\nuser-id: [FILTERED]\r\nuser-api-key: [FILTERED]\r\ndeveloper-id: [FILTERED]\r\nconnection: close\r\naccept-encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nuser-agent: Ruby\r\nhost: api.sandbox.fortis.tech\r\ncontent-length: 154\r\n\r\n" + <- "{\"transaction_amount\":\"100\",\"order_number\":null,\"account_number\":\"[FILTERED]\",\"exp_date\":\"0925\",\"cvv\":\"[FILTERED]\",\"account_holder_name\":\"Longbob Longsen\"}" + -> "HTTP/1.1 201 Created\r\n" + -> "Date: Fri, 15 Nov 2024 17:00:42 GMT\r\n" + -> "Content-Type: application/json\r\n" + -> "Content-Length: 2031\r\n" + -> "Connection: close\r\n" + -> "x-amzn-RequestId: dfd852a8-5c39-4558-9f05-8eaa14affac9\r\n" + -> "Access-Control-Allow-Origin: *\r\n" + -> "x-amz-apigw-id: BTCoUG6SIAMEsdw=\r\n" + -> "X-Amzn-Trace-Id: Root=1-67377e34-170c289b473cbb2e56aeab61;Parent=7e76c3e737bb48c3;Sampled=0;Lineage=1:ae593ade:0\r\n" + -> "Access-Control-Max-Age: 86400\r\n" + -> "Access-Control-Allow-Credentials: true\r\n" + -> "\r\n" + reading 2031 bytes... + -> "{\"type\":\"Transaction\",\"data\":{\"id\":\"31efa3732483237895c9a23d\",\"payment_method\":\"cc\",\"account_vault_id\":null,\"recurring_id\":null,\"first_six\":\"545454\",\"last_four\":\"5454\",\"account_holder_name\":\"Longbob Longsen\",\"transaction_amount\":100,\"description\":null,\"transaction_code\":null,\"avs\":null,\"batch\":null,\"verbiage\":\"Test 7957\",\"transaction_settlement_status\":null,\"effective_date\":null,\"return_date\":null,\"created_ts\":1731690040,\"modified_ts\":1731690040,\"transaction_api_id\":null,\"terms_agree\":null,\"notification_email_address\":null,\"notification_email_sent\":true,\"notification_phone\":null,\"response_message\":null,\"auth_amount\":100,\"auth_code\":\"a37325\",\"type_id\":20,\"location_id\":\"11ef69fdc684ae30b436c55b\",\"reason_code_id\":1000,\"contact_id\":null,\"product_transaction_id\":\"11ef69fdc6debc2cb1af505c\",\"tax\":0,\"customer_ip\":\"34.234.17.123\",\"customer_id\":null,\"po_number\":null,\"avs_enhanced\":\"V\",\"cvv_response\":\"N\",\"cavv_result\":null,\"clerk_number\":null,\"tip_amount\":0,\"created_user_id\":\"11ef69fdc8fd8db2b07213de\",\"modified_user_id\":\"11ef69fdc8fd8db2b07213de\",\"ach_identifier\":null,\"check_number\":null,\"recurring_flag\":\"no\",\"installment_counter\":null,\"installment_total\":null,\"settle_date\":null,\"charge_back_date\":null,\"void_date\":null,\"account_type\":\"mc\",\"is_recurring\":false,\"is_accountvault\":false,\"transaction_c1\":null,\"transaction_c2\":null,\"transaction_c3\":null,\"additional_amounts\":[],\"terminal_serial_number\":null,\"entry_mode_id\":\"K\",\"terminal_id\":null,\"quick_invoice_id\":null,\"ach_sec_code\":null,\"custom_data\":null,\"ebt_type\":null,\"voucher_number\":null,\"hosted_payment_page_id\":null,\"transaction_batch_id\":null,\"currency_code\":840,\"par\":\"ZZZZZZZZZZZZZZZZZZZZ545454545\",\"stan\":null,\"currency\":\"USD\",\"secondary_amount\":0,\"card_bin\":\"545454\",\"paylink_id\":null,\"emv_receipt_data\":null,\"status_code\":102,\"token_id\":null,\"wallet_type\":null,\"order_number\":\"963274518498\",\"routing_number\":null,\"trx_source_code\":12,\"billing_address\":{\"city\":null,\"state\":null,\"postal_code\":null,\"phone\":null,\"country\":null,\"street\":null},\"is_token\":false}}" + PRE + end + + def successful_authorize_response + <<-JSON + { + "type": "Transaction", + "data": { + "id": "31efa361a11da7588f260af5", + "payment_method": "cc", + "account_vault_id": null, + "recurring_id": null, + "first_six": "545454", + "last_four": "5454", + "account_holder_name": "smith", + "transaction_amount": 699, + "description": null, + "transaction_code": null, + "avs": null, + "batch": null, + "verbiage": "Test 4669", + "transaction_settlement_status": null, + "effective_date": null, + "return_date": null, + "created_ts": 1731682518, + "modified_ts": 1731682518, + "transaction_api_id": null, + "terms_agree": null, + "notification_email_address": null, + "notification_email_sent": true, + "notification_phone": null, + "response_message": null, + "auth_amount": 699, + "auth_code": "a361a2", + "type_id": 20, + "location_id": "11ef69fdc684ae30b436c55b", + "reason_code_id": 1000, + "contact_id": null, + "product_transaction_id": "11ef69fdc6debc2cb1af505c", + "tax": 0, + "customer_ip": "34.234.17.123", + "customer_id": null, + "po_number": null, + "avs_enhanced": "V", + "cvv_response": "N", + "cavv_result": null, + "clerk_number": null, + "tip_amount": 0, + "created_user_id": "11ef69fdc8fd8db2b07213de", + "modified_user_id": "11ef69fdc8fd8db2b07213de", + "ach_identifier": null, + "check_number": null, + "recurring_flag": "no", + "installment_counter": null, + "installment_total": null, + "settle_date": null, + "charge_back_date": null, + "void_date": null, + "account_type": "mc", + "is_recurring": false, + "is_accountvault": false, + "transaction_c1": null, + "transaction_c2": null, + "transaction_c3": null, + "additional_amounts": [], + "terminal_serial_number": null, + "entry_mode_id": "K", + "terminal_id": null, + "quick_invoice_id": null, + "ach_sec_code": null, + "custom_data": null, + "ebt_type": null, + "voucher_number": null, + "hosted_payment_page_id": null, + "transaction_batch_id": null, + "currency_code": 840, + "par": "ZZZZZZZZZZZZZZZZZZZZ545454545", + "stan": null, + "currency": "USD", + "secondary_amount": 0, + "card_bin": "545454", + "paylink_id": null, + "emv_receipt_data": null, + "status_code": 102, + "token_id": null, + "wallet_type": null, + "order_number": "865934726945", + "routing_number": null, + "trx_source_code": 12, + "billing_address": { + "city": null, + "state": null, + "postal_code": null, + "phone": null, + "country": null, + "street": null + }, + "is_token": false + } + } + JSON + end +end