lib/active_merchant/billing/gateways/sage_pay.rb
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
class SagePayGateway < Gateway
cattr_accessor :simulate
self.simulate = false
class_attribute :simulator_url
self.test_url = 'https://sandbox.opayo.eu.elavon.com/gateway/service'
self.live_url = 'https://live.opayo.eu.elavon.com/gateway/service'
self.simulator_url = 'https://test.sagepay.com/Simulator'
APPROVED = 'OK'
TRANSACTIONS = {
purchase: 'PAYMENT',
credit: 'REFUND',
authorization: 'DEFERRED',
capture: 'RELEASE',
void: 'VOID',
abort: 'ABORT',
store: 'TOKEN',
unstore: 'REMOVETOKEN',
repeat: 'REPEAT'
}
CREDIT_CARDS = {
visa: 'VISA',
master: 'MC',
delta: 'DELTA',
maestro: 'MAESTRO',
american_express: 'AMEX',
electron: 'UKE',
diners_club: 'DC',
jcb: 'JCB'
}
AVS_CODE = {
'NOTPROVIDED' => nil,
'NOTCHECKED' => 'X',
'MATCHED' => 'Y',
'NOTMATCHED' => 'N'
}
CVV_CODE = {
'NOTPROVIDED' => 'S',
'NOTCHECKED' => 'X',
'MATCHED' => 'M',
'NOTMATCHED' => 'N'
}
OPTIONAL_REQUEST_FIELDS = {
paypal_callback_url: :PayPalCallbackURL,
basket: :Basket,
gift_aid_payment: :GiftAidPayment,
apply_avscv2: :ApplyAVSCV2,
apply_3d_secure: :Apply3DSecure,
account_type: :AccountType,
billing_agreement: :BillingAgreement,
basket_xml: :BasketXML,
customer_xml: :CustomerXML,
surcharge_xml: :SurchargeXML,
vendor_data: :VendorData,
language: :Language,
website: :Website,
recipient_account_number: :FIRecipientAcctNumber,
recipient_surname: :FIRecipientSurname,
recipient_postcode: :FIRecipientPostcode,
recipient_dob: :FIRecipientDoB
}
self.supported_countries = %w[GB IE]
self.supported_cardtypes = %i[visa master american_express discover jcb maestro diners_club]
self.default_currency = 'GBP'
self.homepage_url = 'http://www.sagepay.com'
self.display_name = 'SagePay'
def initialize(options = {})
requires!(options, :login)
@protocol_version = options.fetch(:protocol_version, '3.00')
super
end
def purchase(money, payment_method, options = {})
requires!(options, :order_id)
post = {}
add_override_protocol_version(options)
add_three_ds_data(post, options)
add_stored_credentials_data(post, options)
add_amount(post, money, options)
add_invoice(post, options)
add_payment_method(post, payment_method, options)
add_address(post, options)
add_customer_data(post, options)
add_optional_data(post, options)
commit((past_purchase_reference?(payment_method) ? :repeat : :purchase), post)
end
def authorize(money, payment_method, options = {})
requires!(options, :order_id)
post = {}
add_three_ds_data(post, options)
add_stored_credentials_data(post, options)
add_override_protocol_version(options)
add_amount(post, money, options)
add_invoice(post, options)
add_payment_method(post, payment_method, options)
add_address(post, options)
add_customer_data(post, options)
add_optional_data(post, options)
commit(:authorization, post)
end
# You can only capture a transaction once, even if you didn't capture the full amount the first time.
def capture(money, identification, options = {})
post = {}
add_override_protocol_version(options)
add_reference(post, identification)
add_release_amount(post, money, options)
commit(:capture, post)
end
def void(identification, options = {})
post = {}
add_override_protocol_version(options)
add_reference(post, identification)
action = abort_or_void_from(identification)
commit(action, post)
end
# Refunding requires a new order_id to passed in, as well as a description
def refund(money, identification, options = {})
requires!(options, :order_id, :description)
post = {}
add_override_protocol_version(options)
add_related_reference(post, identification)
add_amount(post, money, options)
add_invoice(post, options)
commit(:credit, post)
end
def credit(money, identification, options = {})
ActiveMerchant.deprecated CREDIT_DEPRECATION_MESSAGE
refund(money, identification, options)
end
def store(credit_card, options = {})
post = {}
add_override_protocol_version(options)
add_credit_card(post, credit_card)
add_currency(post, 0, options)
commit(:store, post)
end
def unstore(token, options = {})
post = {}
add_override_protocol_version(options)
add_token(post, token)
commit(:unstore, post)
end
def verify(credit_card, options = {})
MultiResponse.run(:use_first_response) do |r|
r.process { authorize(100, credit_card, options) }
r.process(:ignore_result) { void(r.authorization, options) }
end
end
def supports_scrubbing
true
end
def scrub(transcript)
transcript.
gsub(%r((Authorization: Basic )\w+), '\1[FILTERED]').
gsub(%r((&?CardNumber=)\d+(&?)), '\1[FILTERED]\2').
gsub(%r((&?CV2=)\d+(&?)), '\1[FILTERED]\2')
end
private
def add_override_protocol_version(options)
@protocol_version = options[:protocol_version] if options[:protocol_version]
end
def add_three_ds_data(post, options)
return unless @protocol_version == '4.00'
return unless three_ds_2_options = options[:three_ds_2]
add_pair(post, :ThreeDSNotificationURL, three_ds_2_options[:notification_url])
return unless three_ds_2_options[:browser_info]
add_browser_info(post, three_ds_2_options[:browser_info])
end
def add_browser_info(post, browser_info)
add_pair(post, :BrowserAcceptHeader, browser_info[:accept_header])
add_pair(post, :BrowserColorDepth, browser_info[:depth])
add_pair(post, :BrowserJavascriptEnabled, format_boolean(browser_info[:java]))
add_pair(post, :BrowserJavaEnabled, format_boolean(browser_info[:java]))
add_pair(post, :BrowserLanguage, browser_info[:language])
add_pair(post, :BrowserScreenHeight, browser_info[:height])
add_pair(post, :BrowserScreenWidth, browser_info[:width])
add_pair(post, :BrowserTZ, browser_info[:timezone])
add_pair(post, :BrowserUserAgent, browser_info[:user_agent])
add_pair(post, :ChallengeWindowSize, browser_info[:browser_size])
end
def add_stored_credentials_data(post, options)
return unless @protocol_version == '4.00'
return unless stored_credential = options[:stored_credential]
initiator = stored_credential[:initiator] == 'cardholder' ? 'CIT' : 'MIT'
cof_usage = if stored_credential[:initial_transaction] && initiator == 'CIT'
'FIRST'
elsif !stored_credential[:initial_transaction] && initiator == 'MIT'
'SUBSEQUENT'
end
add_pair(post, :COFUsage, cof_usage) if cof_usage
add_pair(post, :InitiatedTYPE, initiator)
add_pair(post, :SchemeTraceID, stored_credential[:network_transaction_id]) if stored_credential[:network_transaction_id]
reasoning = stored_credential[:reason_type] == 'installment' ? 'instalment' : stored_credential[:reason_type]
add_pair(post, :MITType, reasoning.upcase)
if %w(instalment recurring).any?(reasoning)
add_pair(post, :RecurringExpiry, options[:recurring_expiry])
add_pair(post, :RecurringFrequency, options[:recurring_frequency])
add_pair(post, :PurchaseInstalData, options[:installment_data])
end
end
def truncate(value, max_size)
return nil unless value
return value.to_s if CGI.escape(value.to_s).length <= max_size
if value.size > max_size
truncate(super(value, max_size), max_size)
else
truncate(value.to_s.chop, max_size)
end
end
def add_reference(post, identification)
order_id, transaction_id, authorization, security_key = identification.split(';')
add_pair(post, :VendorTxCode, order_id)
add_pair(post, :VPSTxId, transaction_id)
add_pair(post, :TxAuthNo, authorization)
add_pair(post, :SecurityKey, security_key)
end
def add_related_reference(post, identification)
order_id, transaction_id, authorization, security_key = identification.split(';')
add_pair(post, :RelatedVendorTxCode, order_id)
add_pair(post, :RelatedVPSTxId, transaction_id)
add_pair(post, :RelatedTxAuthNo, authorization)
add_pair(post, :RelatedSecurityKey, security_key)
end
def add_amount(post, money, options)
currency = options[:currency] || currency(money)
add_pair(post, :Amount, localized_amount(money, currency), required: true)
add_pair(post, :Currency, currency, required: true)
end
def add_currency(post, money, options)
currency = options[:currency] || currency(money)
add_pair(post, :Currency, currency, required: true)
end
# doesn't actually use the currency -- dodgy!
def add_release_amount(post, money, options)
add_pair(post, :ReleaseAmount, amount(money), required: true)
end
def add_customer_data(post, options)
add_pair(post, :CustomerEMail, truncate(options[:email], 255)) unless options[:email].blank?
add_pair(post, :ClientIPAddress, options[:ip])
end
def add_optional_data(post, options)
add_pair(post, :CreateToken, 1) unless options[:store].blank?
OPTIONAL_REQUEST_FIELDS.each do |gateway_option, sagepay_field|
add_pair(post, sagepay_field, options[gateway_option])
end
end
def add_address(post, options)
if billing_address = options[:billing_address] || options[:address]
first_name, last_name = split_names(billing_address[:name])
add_pair(post, :BillingSurname, truncate(last_name, 20))
add_pair(post, :BillingFirstnames, truncate(first_name, 20))
add_pair(post, :BillingAddress1, truncate(billing_address[:address1], 100))
add_pair(post, :BillingAddress2, truncate(billing_address[:address2], 100))
add_pair(post, :BillingCity, truncate(billing_address[:city], 40))
add_pair(post, :BillingState, truncate(billing_address[:state], 2)) if usa?(billing_address[:country])
add_pair(post, :BillingCountry, truncate(billing_address[:country], 2))
add_pair(post, :BillingPhone, sanitize_phone(billing_address[:phone]))
add_pair(post, :BillingPostCode, truncate(billing_address[:zip], 10))
end
if shipping_address = options[:shipping_address] || billing_address
first_name, last_name = split_names(shipping_address[:name])
add_pair(post, :DeliverySurname, truncate(last_name, 20))
add_pair(post, :DeliveryFirstnames, truncate(first_name, 20))
add_pair(post, :DeliveryAddress1, truncate(shipping_address[:address1], 100))
add_pair(post, :DeliveryAddress2, truncate(shipping_address[:address2], 100))
add_pair(post, :DeliveryCity, truncate(shipping_address[:city], 40))
add_pair(post, :DeliveryState, truncate(shipping_address[:state], 2)) if usa?(shipping_address[:country])
add_pair(post, :DeliveryCountry, truncate(shipping_address[:country], 2))
add_pair(post, :DeliveryPhone, sanitize_phone(shipping_address[:phone]))
add_pair(post, :DeliveryPostCode, truncate(shipping_address[:zip], 10))
end
end
def add_invoice(post, options)
add_pair(post, :VendorTxCode, sanitize_order_id(options[:order_id]), required: true)
add_pair(post, :Description, truncate(options[:description] || options[:order_id], 100))
end
def add_payment_method(post, payment_method, options)
if payment_method.is_a?(String)
if past_purchase_reference?(payment_method)
add_related_reference(post, payment_method)
else
add_token_details(post, payment_method, options)
end
else
add_credit_card(post, payment_method)
end
end
def add_credit_card(post, credit_card)
add_pair(post, :CardHolder, truncate(credit_card.name, 50), required: true)
add_pair(post, :CardNumber, credit_card.number, required: true)
add_pair(post, :ExpiryDate, format_date(credit_card.month, credit_card.year), required: true)
add_pair(post, :CardType, map_card_type(credit_card))
add_pair(post, :CV2, credit_card.verification_value)
end
def add_token_details(post, token, options)
add_token(post, token)
add_pair(post, :StoreToken, options[:customer])
add_pair(post, :CV2, options[:verification_value])
end
def add_token(post, token)
add_pair(post, :Token, token)
end
def sanitize_order_id(order_id)
cleansed = order_id.to_s.gsub(/[^-a-zA-Z0-9._]/, '')
truncate(cleansed, 40)
end
def sanitize_phone(phone)
return nil unless phone
cleansed = phone.to_s.gsub(/[^0-9+]/, '')
truncate(cleansed, 20)
end
def usa?(country)
truncate(country, 2) == 'US'
end
def map_card_type(credit_card)
raise ArgumentError, 'The credit card type must be provided' if card_brand(credit_card).blank?
card_type = card_brand(credit_card).to_sym
if card_type == :visa && credit_card.electron?
CREDIT_CARDS[:electron]
else
CREDIT_CARDS[card_type]
end
end
# MMYY format
def format_date(month, year)
return nil if year.blank? || month.blank?
year = sprintf('%.4i', year)
month = sprintf('%.2i', month)
"#{month}#{year[-2..-1]}"
end
def commit(action, parameters)
response = parse(ssl_post(url_for(action), post_data(action, parameters)))
Response.new(
response['Status'] == APPROVED,
message_from(response),
response,
test: test?,
authorization: authorization_from(response, parameters, action),
avs_result: {
street_match: AVS_CODE[response['AddressResult']],
postal_match: AVS_CODE[response['PostCodeResult']]
},
cvv_result: CVV_CODE[response['CV2Result']]
)
end
def authorization_from(response, params, action)
case action
when :store
response['Token']
else
[params[:VendorTxCode],
response['VPSTxId'] || params[:VPSTxId],
response['TxAuthNo'],
response['SecurityKey'] || params[:SecurityKey],
action].join(';')
end
end
def abort_or_void_from(identification)
original_transaction = identification.split(';').last
original_transaction == 'authorization' ? :abort : :void
end
def url_for(action)
simulate ? build_simulator_url(action) : build_url(action)
end
def build_url(action)
endpoint =
case action
when :purchase, :authorization then 'vspdirect-register'
when :store then 'directtoken'
else TRANSACTIONS[action].downcase
end
"#{test? ? self.test_url : self.live_url}/#{endpoint}.vsp"
end
def build_simulator_url(action)
endpoint = %i[purchase authorization].include?(action) ? 'VSPDirectGateway.asp' : "VSPServerGateway.asp?Service=Vendor#{TRANSACTIONS[action].capitalize}Tx"
"#{self.simulator_url}/#{endpoint}"
end
def message_from(response)
response['Status'] == APPROVED ? 'Success' : (response['StatusDetail'] || 'Unspecified error') # simonr 20080207 can't actually get non-nil blanks, so this is shorter
end
def post_data(action, parameters = {})
parameters.update(
Vendor: @options[:login],
TxType: TRANSACTIONS[action],
VPSProtocol: @protocol_version
)
parameters.update(ReferrerID: application_id) if application_id && (application_id != Gateway.application_id)
parameters.collect { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join('&')
end
def format_boolean(value)
return if value.nil?
value ? '1' : '0'
end
# SagePay returns data in the following format
# Key1=value1
# Key2=value2
def parse(body)
result = {}
body.to_s.each_line do |pair|
result[$1] = $2 if pair.strip =~ /\A([^=]+)=(.+)\Z/im
end
result
end
def add_pair(post, key, value, options = {})
post[key] = value if !value.blank? || options[:required]
end
def past_purchase_reference?(payment_method)
return false unless payment_method.is_a?(String)
%w(purchase repeat).include?(payment_method.split(';').last)
end
end
end
end