-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ❓ There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this change related to the HTTP version ❓ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exactly, see: the headers object is a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reponses:
|
||
|
||
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 |
There was a problem hiding this comment.
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 ❓
There was a problem hiding this comment.
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