# coding: utf-8
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
# = Redsys Merchant Gateway
#
# Gateway support for the Spanish "Redsys" payment gateway system. This is
# used by many banks in Spain and is particularly well supported by
# Catalunya Caixa's ecommerce department.
#
# Redsys requires an order_id be provided with each transaction and it must
# follow a specific format. The rules are as follows:
#
# * First 4 digits must be numerical
# * Remaining 8 digits may be alphanumeric
# * Max length: 12
#
# If an invalid order_id is provided, we do our best to clean it up.
#
# Written by Piers Chambers (Varyonic.com)
#
# *** SHA256 Authentication Update ***
#
# Redsys has dropped support for the SHA1 authentication method.
# Developer documentation: https://pagosonline.redsys.es/desarrolladores.html
class RedsysRestGateway < Gateway
self.test_url = 'https://sis-t.redsys.es:25443/sis/rest/'
self.live_url = 'https://sis.redsys.es/sis/rest/'
self.supported_countries = ['ES']
self.default_currency = 'EUR'
self.money_format = :cents
# Not all card types may be activated by the bank!
self.supported_cardtypes = %i[visa master american_express jcb diners_club unionpay]
self.homepage_url = 'http://www.redsys.es/'
self.display_name = 'Redsys (REST)'
CURRENCY_CODES = {
'AED' => '784',
'ARS' => '32',
'AUD' => '36',
'BRL' => '986',
'BOB' => '68',
'CAD' => '124',
'CHF' => '756',
'CLP' => '152',
'CNY' => '156',
'COP' => '170',
'CRC' => '188',
'CZK' => '203',
'DKK' => '208',
'DOP' => '214',
'EUR' => '978',
'GBP' => '826',
'GTQ' => '320',
'HUF' => '348',
'IDR' => '360',
'INR' => '356',
'JPY' => '392',
'KRW' => '410',
'MYR' => '458',
'MXN' => '484',
'NOK' => '578',
'NZD' => '554',
'PEN' => '604',
'PLN' => '985',
'RUB' => '643',
'SAR' => '682',
'SEK' => '752',
'SGD' => '702',
'THB' => '764',
'TWD' => '901',
'USD' => '840',
'UYU' => '858'
}
THREEDS_EXEMPTIONS = {
corporate_card: 'COR',
delegated_authentication: 'ATD',
low_risk: 'TRA',
low_value: 'LWV',
stored_credential: 'MIT',
trusted_merchant: 'NDF'
}
# The set of supported transactions for this gateway.
# More operations are supported by the gateway itself, but
# are not supported in this library.
SUPPORTED_TRANSACTIONS = {
purchase: '0',
authorize: '1',
capture: '2',
refund: '3',
cancel: '9',
verify: '7'
}
# These are the text meanings sent back by the acquirer when
# a card has been rejected. Syntax or general request errors
# are not covered here.
RESPONSE_TEXTS = {
0 => 'Transaction Approved',
400 => 'Cancellation Accepted',
481 => 'Cancellation Accepted',
500 => 'Reconciliation Accepted',
900 => 'Refund / Confirmation approved',
101 => 'Card expired',
102 => 'Card blocked temporarily or under susciption of fraud',
104 => 'Transaction not permitted',
107 => 'Contact the card issuer',
109 => 'Invalid identification by merchant or POS terminal',
110 => 'Invalid amount',
114 => 'Card cannot be used to the requested transaction',
116 => 'Insufficient credit',
118 => 'Non-registered card',
125 => 'Card not effective',
129 => 'CVV2/CVC2 Error',
167 => 'Contact the card issuer: suspected fraud',
180 => 'Card out of service',
181 => 'Card with credit or debit restrictions',
182 => 'Card with credit or debit restrictions',
184 => 'Authentication error',
190 => 'Refusal with no specific reason',
191 => 'Expiry date incorrect',
195 => 'Requires SCA authentication',
201 => 'Card expired',
202 => 'Card blocked temporarily or under suspicion of fraud',
204 => 'Transaction not permitted',
207 => 'Contact the card issuer',
208 => 'Lost or stolen card',
209 => 'Lost or stolen card',
280 => 'CVV2/CVC2 Error',
290 => 'Declined with no specific reason',
480 => 'Original transaction not located, or time-out exceeded',
501 => 'Original transaction not located, or time-out exceeded',
502 => 'Original transaction not located, or time-out exceeded',
503 => 'Original transaction not located, or time-out exceeded',
904 => 'Merchant not registered at FUC',
909 => 'System error',
912 => 'Issuer not available',
913 => 'Duplicate transmission',
916 => 'Amount too low',
928 => 'Time-out exceeded',
940 => 'Transaction cancelled previously',
941 => 'Authorization operation already cancelled',
942 => 'Original authorization declined',
943 => 'Different details from origin transaction',
944 => 'Session error',
945 => 'Duplicate transmission',
946 => 'Cancellation of transaction while in progress',
947 => 'Duplicate tranmission while in progress',
949 => 'POS Inoperative',
950 => 'Refund not possible',
9064 => 'Card number incorrect',
9078 => 'No payment method available',
9093 => 'Non-existent card',
9218 => 'Recursive transaction in bad gateway',
9253 => 'Check-digit incorrect',
9256 => 'Preauth not allowed for merchant',
9257 => 'Preauth not allowed for card',
9261 => 'Operating limit exceeded',
9912 => 'Issuer not available',
9913 => 'Confirmation error',
9914 => 'KO Confirmation'
}
# Expected values as per documentation
THREE_DS_V2 = '2.1.0'
# Creates a new instance
#
# Redsys requires a login and secret_key, and optionally also accepts a
# non-default terminal.
#
# ==== Options
#
# * :login -- The Redsys Merchant ID (REQUIRED)
# * :secret_key -- The Redsys Secret Key. (REQUIRED)
# * :terminal -- The Redsys Terminal. Defaults to 1. (OPTIONAL)
# * :test -- +true+ or +false+. Defaults to +false+. (OPTIONAL)
def initialize(options = {})
requires!(options, :login, :secret_key)
options[:terminal] ||= 1
options[:signature_algorithm] = 'sha256'
super
end
def purchase(money, payment, options = {})
requires!(options, :order_id)
post = {}
add_action(post, :purchase, options)
add_amount(post, money, options)
add_stored_credentials(post, options)
add_threeds_exemption_data(post, options)
add_order(post, options[:order_id])
add_payment(post, payment)
add_description(post, options)
add_direct_payment(post, options)
add_threeds(post, options)
commit(post, options)
end
def authorize(money, payment, options = {})
requires!(options, :order_id)
post = {}
add_action(post, :authorize, options)
add_amount(post, money, options)
add_stored_credentials(post, options)
add_threeds_exemption_data(post, options)
add_order(post, options[:order_id])
add_payment(post, payment)
add_description(post, options)
add_direct_payment(post, options)
add_threeds(post, options)
commit(post, options)
end
def capture(money, authorization, options = {})
post = {}
add_action(post, :capture)
add_amount(post, money, options)
order_id, = split_authorization(authorization)
add_order(post, order_id)
add_description(post, options)
commit(post, options)
end
def void(authorization, options = {})
requires!(options, :order_id)
post = {}
add_action(post, :cancel)
order_id, amount, currency = split_authorization(authorization)
add_amount(post, amount, currency: currency)
add_order(post, order_id)
add_description(post, options)
commit(post, options)
end
def refund(money, authorization, options = {})
requires!(options, :order_id)
post = {}
add_action(post, :refund)
add_amount(post, money, options)
order_id, = split_authorization(authorization)
add_order(post, order_id)
add_description(post, options)
commit(post, options)
end
def verify(creditcard, options = {})
requires!(options, :order_id)
post = {}
add_action(post, :verify, options)
add_amount(post, 0, options)
add_order(post, options[:order_id])
add_payment(post, creditcard)
add_description(post, options)
add_direct_payment(post, options)
add_threeds(post, options)
commit(post, options)
end
def supports_scrubbing?
true
end
def scrub(transcript)
transcript.
gsub(%r((PAN\"=>\")(\d+)), '\1[FILTERED]').
gsub(%r((CVV2\"=>\")(\d+)), '\1[FILTERED]')
end
private
def add_direct_payment(post, options)
# Direct payment skips 3DS authentication. We should only apply this if execute_threed is false
# or authentication data is not present. Authentication data support to be added in the future.
return if options[:execute_threed] || options[:authentication_data] || options[:three_ds_exemption_type] == 'moto'
post[:DS_MERCHANT_DIRECTPAYMENT] = true
end
def add_threeds(post, options)
return unless options[:execute_threed] || options[:three_ds_2]
post[:DS_MERCHANT_EMV3DS] = if options[:execute_threed]
{ threeDSInfo: 'CardData' }
else
add_browser_info(post, options)
end
end
def add_browser_info(post, options)
return unless browser_info = options.dig(:three_ds_2, :browser_info)
{
browserAcceptHeader: browser_info[:accept_header],
browserUserAgent: browser_info[:user_agent],
browserJavaEnabled: browser_info[:java],
browserJavascriptEnabled: browser_info[:java],
browserLanguage: browser_info[:language],
browserColorDepth: browser_info[:depth],
browserScreenHeight: browser_info[:height],
browserScreenWidth: browser_info[:width],
browserTZ: browser_info[:timezone]
}
end
def add_action(post, action, options = {})
post[:DS_MERCHANT_TRANSACTIONTYPE] = transaction_code(action)
end
def add_amount(post, money, options)
post[:DS_MERCHANT_AMOUNT] = amount(money).to_s
post[:DS_MERCHANT_CURRENCY] = currency_code(options[:currency] || currency(money))
end
def add_description(post, options)
post[:DS_MERCHANT_PRODUCTDESCRIPTION] = CGI.escape(options[:description]) if options[:description]
end
def add_order(post, order_id)
post[:DS_MERCHANT_ORDER] = clean_order_id(order_id)
end
def add_payment(post, card)
name = [card.first_name, card.last_name].join(' ').slice(0, 60)
year = sprintf('%.4i', card.year)
month = sprintf('%.2i', card.month)
post['DS_MERCHANT_TITULAR'] = CGI.escape(name)
post['DS_MERCHANT_PAN'] = card.number
post['DS_MERCHANT_EXPIRYDATE'] = "#{year[2..3]}#{month}"
post['DS_MERCHANT_CVV2'] = card.verification_value if card.verification_value.present?
end
def determine_action(options)
# If execute_threed is true, we need to use iniciaPeticionREST to set up authentication
# Otherwise we are skipping 3DS or we should have 3DS authentication results
options[:execute_threed] ? 'iniciaPeticionREST' : 'trataPeticionREST'
end
def commit(post, options)
url = (test? ? test_url : live_url)
action = determine_action(options)
raw_response = parse(ssl_post(url + action, post_data(post, options)))
payload = raw_response['Ds_MerchantParameters']
return Response.new(false, "#{raw_response['errorCode']} ERROR") unless payload
response = JSON.parse(Base64.decode64(payload)).transform_keys!(&:downcase).with_indifferent_access
return Response.new(false, 'Unable to verify response') unless validate_signature(payload, raw_response['Ds_Signature'], response[:ds_order])
succeeded = success_from(response, options)
Response.new(
succeeded,
message_from(response),
response,
authorization: authorization_from(response),
test: test?,
error_code: succeeded ? nil : response[:ds_response]
)
end
def post_data(post, options)
add_authentication(post, options)
merchant_parameters = JSON.generate(post)
encoded_parameters = Base64.strict_encode64(merchant_parameters)
post_data = PostData.new
post_data['Ds_SignatureVersion'] = 'HMAC_SHA256_V1'
post_data['Ds_MerchantParameters'] = encoded_parameters
post_data['Ds_Signature'] = sign_request(encoded_parameters, post[:DS_MERCHANT_ORDER])
post_data.to_post_data
end
def add_authentication(post, options)
post[:DS_MERCHANT_TERMINAL] = options[:terminal] || @options[:terminal]
post[:DS_MERCHANT_MERCHANTCODE] = @options[:login]
end
def add_stored_credentials(post, options)
return unless stored_credential = options[:stored_credential]
post[:DS_MERCHANT_COF_INI] = stored_credential[:initial_transaction] ? 'S' : 'N'
post[:DS_MERCHANT_COF_TYPE] = case stored_credential[:reason_type]
when 'recurring'
'R'
when 'installment'
'I'
else
'C'
end
post[:DS_MERCHANT_IDENTIFIER] = 'REQUIRED' if stored_credential[:initiator] == 'cardholder'
post[:DS_MERCHANT_COF_TXNID] = stored_credential[:network_transaction_id] if stored_credential[:network_transaction_id]
end
def add_threeds_exemption_data(post, options)
return unless options[:three_ds_exemption_type]
if options[:three_ds_exemption_type] == 'moto'
post[:DS_MERCHANT_DIRECTPAYMENT] = 'MOTO'
else
exemption = options[:three_ds_exemption_type].to_sym
post[:DS_MERCHANT_EXCEP_SCA] = THREEDS_EXEMPTIONS[exemption]
end
end
def parse(body)
JSON.parse(body)
end
def success_from(response, options)
return true if response[:ds_emv3ds] && options[:execute_threed]
# Need to get updated for 3DS support
if code = response[:ds_response]
(code.to_i < 100) || [400, 481, 500, 900].include?(code.to_i)
else
false
end
end
def message_from(response)
return response.dig(:ds_emv3ds, :threeDSInfo) if response[:ds_emv3ds]
code = response[:ds_response]&.to_i
code = 0 if code < 100
RESPONSE_TEXTS[code] || 'Unknown code, please check in manual'
end
def validate_signature(data, signature, order_number)
key = encrypt(@options[:secret_key], order_number)
Base64.urlsafe_encode64(mac256(key, data)) == signature
end
def authorization_from(response)
# Need to get updated for 3DS support
[response[:ds_order], response[:ds_amount], response[:ds_currency]].join('|')
end
def split_authorization(authorization)
order_id, amount, currency = authorization.split('|')
[order_id, amount.to_i, currency]
end
def currency_code(currency)
return currency if currency =~ /^\d+$/
raise ArgumentError, "Unknown currency #{currency}" unless CURRENCY_CODES[currency]
CURRENCY_CODES[currency]
end
def transaction_code(type)
SUPPORTED_TRANSACTIONS[type]
end
def clean_order_id(order_id)
cleansed = order_id.gsub(/[^\da-zA-Z]/, '')
if /^\d{4}/.match?(cleansed)
cleansed[0..11]
else
'%04d' % [rand(0..9999)] + cleansed[0...8]
end
end
def sign_request(encoded_parameters, order_id)
raise(ArgumentError, 'missing order_id') unless order_id
key = encrypt(@options[:secret_key], order_id)
Base64.strict_encode64(mac256(key, encoded_parameters))
end
def encrypt(key, order_id)
block_length = 8
cipher = OpenSSL::Cipher.new('DES3')
cipher.encrypt
cipher.key = Base64.urlsafe_decode64(key)
# The OpenSSL default of an all-zeroes ("\\0") IV is used.
cipher.padding = 0
order_id += "\0" until order_id.bytesize % block_length == 0 # Pad with zeros
cipher.update(order_id) + cipher.final
end
def mac256(key, data)
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, data)
end
end
end
end