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