Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fortis: Initial implementation #5349

Merged
merged 1 commit into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ RuboCop::RakeTask.new

namespace :test do
Rake::TestTask.new(:units) do |t|
ENV['RUNNING_UNIT_TESTS'] = 'true'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this ENV needed ❓

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, this one is a workaround for preventing Net::HTTP refinement from having some effect on unit tests mocs on test/unit/connection_test.rb

t.pattern = 'test/unit/**/*_test.rb'
t.libs << 'test'
t.verbose = false
Expand Down
331 changes: 331 additions & 0 deletions lib/active_merchant/billing/gateways/fortis.rb
Original file line number Diff line number Diff line change
@@ -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 = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was the mapping for status and reason needed due the Fortis response does not return a readable message, or was it for another specific reason ❓

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the times Fortis returns an status_mapping and a reason_mapping but not the actual message

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)
Heavyblade marked this conversation as resolved.
Show resolved Hide resolved
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
2 changes: 1 addition & 1 deletion lib/active_merchant/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change related to the HTTP version ❓

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, see:

the headers object is a CaseSensitiveHeaders object that acts like a Hash, the merge method called on RUBY_182_POST_HEADERS hash will return a new Hash not a CaseSensitveHeaders that I need in the refinement.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why using reverse merge? Is order of these headers also important?

when :put
debug body
http.put(endpoint.request_uri, body, headers)
Expand Down
35 changes: 35 additions & 0 deletions lib/active_merchant/net_http_ssl_connection.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
require 'net/http'

module NetHttpSslConnection
module InnocuousCapitalize
def capitalize(name) = name
Heavyblade marked this conversation as resolved.
Show resolved Hide resolved
private :capitalize
end

class CaseSensitivePost < Net::HTTP::Post; prepend InnocuousCapitalize; end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the prepend intended to give precedence to the existing behavior, which is capitalizing header fields?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reponses:

  1. no because of order, but because of classes see => hash_object.merge!(case_sensitve_header_hash), en-up being a plain ruby Hash, but case_sensitve_header_hash.merge!(hash_object) updates CaseSensitiveHeaders instant but is still that class, so I can differentiate in the Refinement.

  2. yes exactly that


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
Loading
Loading