# encoding: utf-8 require 'openssl' require 'net/https' require 'base64' require 'digest/sha1' module Ideal # === Response classes # # * Response # * TransactionResponse # * StatusResponse # * DirectoryResponse # # See the Response class for more information on errors. class Gateway AUTHENTICATION_TYPE = 'SHA1_RSA' LANGUAGE = 'nl' CURRENCY = 'EUR' API_VERSION = '1.1.0' XML_NAMESPACE = 'http://www.idealdesk.com/Message' def self.acquirers Ideal::ACQUIRERS end class << self # Returns the current acquirer used attr_reader :acquirer # Holds the environment in which the run (default is test) attr_accessor :environment # Holds the global iDEAL merchant id. Make sure to use a string with # leading zeroes if needed. attr_accessor :merchant_id # Holds the passphrase that should be used for the merchant private_key. attr_accessor :passphrase # Holds the test and production urls for your iDeal acquirer. attr_accessor :live_url, :test_url end # Environment defaults to test self.environment = :test # Loads the global merchant private_key from disk. def self.private_key_file=(pkey_file) self.private_key = File.read(pkey_file) end # Instantiates and assings a OpenSSL::PKey::RSA instance with the # provided private key data. def self.private_key=(pkey_data) @private_key = OpenSSL::PKey::RSA.new(pkey_data, passphrase) end # Returns the global merchant private_certificate. def self.private_key @private_key end # Loads the global merchant private_certificate from disk. def self.private_certificate_file=(certificate_file) self.private_certificate = File.read(certificate_file) end # Instantiates and assings a OpenSSL::X509::Certificate instance with the # provided private certificate data. def self.private_certificate=(certificate_data) @private_certificate = OpenSSL::X509::Certificate.new(certificate_data) end # Returns the global merchant private_certificate. def self.private_certificate @private_certificate end # Loads the global merchant ideal_certificate from disk. def self.ideal_certificate_file=(certificate_file) self.ideal_certificate = File.read(certificate_file) end # Instantiates and assings a OpenSSL::X509::Certificate instance with the # provided iDEAL certificate data. def self.ideal_certificate=(certificate_data) @ideal_certificate = OpenSSL::X509::Certificate.new(certificate_data) end # Returns the global merchant ideal_certificate. def self.ideal_certificate @ideal_certificate end # Returns whether we're in test mode or not. def self.test? environment.to_sym == :test end # Set the correct acquirer url based on the specific Bank # Currently supported arguments: :ing, :rabobank, :abnamro # # Ideal::Gateway.acquirer = :ing def self.acquirer=(acquirer) @acquirer = acquirer.to_s if self.acquirers.include?(@acquirer) acquirers[@acquirer].each do |attr, value| send("#{attr}=", value) end else raise ArgumentError, "Unknown acquirer `#{acquirer}', please choose one of: #{self.acquirers.keys.join(', ')}" end end # Returns the merchant `subID' being used for this Gateway instance. # Defaults to 0. attr_reader :sub_id # Initializes a new Gateway instance. # # You can optionally specify :sub_id. Defaults to 0. def initialize(options = {}) @sub_id = options[:sub_id] || 0 end # Returns the endpoint for the request. # # Automatically uses test or live URLs based on the configuration. def request_url self.class.send("#{self.class.environment}_url") end # Sends a directory request to the acquirer and returns an # DirectoryResponse. Use DirectoryResponse#list to receive the # actuall array of available issuers. # # gateway.issuers.list # => [{ :id => '1006', :name => 'ABN AMRO Bank' }, …] def issuers post_data request_url, build_directory_request_body, DirectoryResponse end # Starts a purchase by sending an acquirer transaction request for the # specified +money+ amount in EURO cents. # # On success returns an TransactionResponse with the #transaction_id # which is needed for the capture step. (See capture for an example.) # # The iDEAL specification states that it is _not_ allowed to use another # window or frame when redirecting the consumer to the issuer. So the # entire merchant’s page has to be replaced by the selected issuer’s page. # # === Options # # Note that all options that have a character limit are _also_ checked # for diacritical characters. If it does contain diacritical characters, # or exceeds the character limit, an ArgumentError is raised. # # ==== Required # # * :issuer_id - The :id of an issuer available at the acquirer to which the transaction should be made. # * :order_id - The order number. Limited to 12 characters. # * :description - A description of the transaction. Limited to 32 characters. # * :return_url - A URL on the merchant’s system to which the consumer is redirected _after_ payment. The acquirer will add the following GET variables: # * trxid - The :order_id. # * ec - The :entrance_code _if_ it was specified. # # ==== Optional # # * :entrance_code - This code is an abitrary token which can be used to identify the transaction besides the :order_id. Limited to 40 characters. # * :expiration_period - The period of validity of the payment request measured from the receipt by the issuer. The consumer must approve the payment within this period, otherwise the StatusResponse#status will be set to `Expired'. E.g., consider an :expiration_period of `P3DT6H10M': # * P: relative time designation. # * 3 days. # * T: separator. # * 6 hours. # * 10 minutes. # # === Example # # transaction_response = gateway.setup_purchase(4321, valid_options) # if transaction_response.success? # @purchase.update_attributes!(:transaction_id => transaction_response.transaction_id) # redirect_to transaction_response.service_url # end # # See the Gateway class description for a more elaborate example. def setup_purchase(money, options) post_data request_url, build_transaction_request_body(money, options), TransactionResponse end # Sends a acquirer status request for the specified +transaction_id+ and # returns an StatusResponse. # # It is _your_ responsibility as the merchant to check if the payment has # been made until you receive a response with a finished status like: # `Success', `Cancelled', `Expired', everything else equals `Open'. # # === Example # # capture_response = gateway.capture(@purchase.transaction_id) # if capture_response.success? # @purchase.update_attributes!(:paid => true) # flash[:notice] = "Congratulations, you are now the proud owner of a Dutch windmill!" # end # # See the Gateway class description for a more elaborate example. def capture(transaction_id) post_data request_url, build_status_request_body(:transaction_id => transaction_id), StatusResponse end private def ssl_post(url, body) log('URL', url) log('Request', body) response = REST.post(url, body, { 'Content-Type' => 'application/xml; charset=utf-8' }, { :tls_verify => true, :tls_key => self.class.private_key, :tls_certificate => self.class.private_certificate }) log('Response', response.body) response.body end def post_data(gateway_url, data, response_klass) response_klass.new(ssl_post(gateway_url, data), :test => self.class.test?) end # This is the list of charaters that are not supported by iDEAL according # to the PHP source provided by ING plus the same in capitals. DIACRITICAL_CHARACTERS = /[ÀÁÂÃÄÅÇŒÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝàáâãäåçæèéêëìíîïñòóôõöøùúûüý]/ #:nodoc: # Raises an ArgumentError if the +string+ exceeds the +max_length+ amount # of characters or contains any diacritical characters. def enforce_maximum_length(key, string, max_length) raise ArgumentError, "The value for `#{key}' exceeds the limit of #{max_length} characters." if string.length > max_length raise ArgumentError, "The value for `#{key}' contains diacritical characters `#{string}'." if string =~ DIACRITICAL_CHARACTERS end # Returns the +token+ as specified in section 2.8.4 of the iDeal specs. # # This is the params['AcquirerStatusRes']['Signature']['fingerprint'] in # a StatusResponse instance. def token Digest::SHA1.hexdigest(self.class.private_certificate.to_der).upcase end def strip_whitespace(str) str.gsub(/\s/m,'') end # Creates a +tokenCode+ from the specified +message+. def token_code(message) signature = self.class.private_key.sign(OpenSSL::Digest::SHA1.new, strip_whitespace(message)) strip_whitespace(Base64.encode64(signature)) end # Returns a string containing the current UTC time, formatted as per the # iDeal specifications, except we don't use miliseconds. def created_at_timestamp Time.now.gmtime.strftime("%Y-%m-%dT%H:%M:%S.000Z") end def camelize(lower_case_and_underscored_word, first_letter_in_uppercase = true) if first_letter_in_uppercase lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase } else lower_case_and_underscored_word.to_s[0].chr.downcase + camelize(lower_case_and_underscored_word)[1..-1] end end # iDeal doesn't really seem to care about nice looking keys in their XML. # Probably some Java XML class, hence the method name. def javaize_key(key) key = key.to_s case key when 'acquirer_transaction_request' 'AcquirerTrxReq' when 'acquirer_status_request' 'AcquirerStatusReq' when 'directory_request' 'DirectoryReq' when 'issuer', 'merchant', 'transaction' key.capitalize when 'created_at' 'createDateTimeStamp' when 'merchant_return_url' 'merchantReturnURL' when 'token_code', 'expiration_period', 'entrance_code' key[0,1] + camelize(key)[1..-1] when /^(\w+)_id$/ "#{$1}ID" else key end end # Creates xml with a given hash of tag-value pairs according to the iDeal # requirements. def xml_for(name, tags_and_values) xml = Builder::XmlMarkup.new xml.instruct! xml.tag!(javaize_key(name), 'xmlns' => XML_NAMESPACE, 'version' => API_VERSION) { xml_from_array(xml, tags_and_values) } xml.target! end # Recursively creates xml for a given hash of tag-value pair. Uses # javaize_key on the tags to create the tags needed by iDeal. def xml_from_array(builder, tags_and_values) tags_and_values.each do |tag, value| tag = javaize_key(tag) if value.is_a?(Array) builder.tag!(tag) { xml_from_array(builder, value) } else builder.tag!(tag, value) end end end def requires!(options, *keys) missing = keys - options.keys unless missing.empty? raise ArgumentError, "Missing required options: #{missing.map { |m| m.to_s }.join(', ')}" end end def build_status_request_body(options) requires!(options, :transaction_id) timestamp = created_at_timestamp message = "#{timestamp}#{self.class.merchant_id}#{@sub_id}#{options[:transaction_id]}" xml_for(:acquirer_status_request, [ [:created_at, timestamp], [:merchant, [ [:merchant_id, self.class.merchant_id], [:sub_id, @sub_id], [:authentication, AUTHENTICATION_TYPE], [:token, token], [:token_code, token_code(message)] ]], [:transaction, [ [:transaction_id, options[:transaction_id]] ]] ]) end def build_directory_request_body timestamp = created_at_timestamp message = "#{timestamp}#{self.class.merchant_id}#{@sub_id}" xml_for(:directory_request, [ [:created_at, timestamp], [:merchant, [ [:merchant_id, self.class.merchant_id], [:sub_id, @sub_id], [:authentication, AUTHENTICATION_TYPE], [:token, token], [:token_code, token_code(message)] ]] ]) end def build_transaction_request_body(money, options) requires!(options, :issuer_id, :expiration_period, :return_url, :order_id, :description, :entrance_code) enforce_maximum_length(:money, money.to_s, 12) enforce_maximum_length(:order_id, options[:order_id], 12) enforce_maximum_length(:description, options[:description], 32) enforce_maximum_length(:entrance_code, options[:entrance_code], 40) timestamp = created_at_timestamp message = timestamp + options[:issuer_id] + self.class.merchant_id + @sub_id.to_s + options[:return_url] + options[:order_id] + money.to_s + CURRENCY + LANGUAGE + options[:description] + options[:entrance_code] xml_for(:acquirer_transaction_request, [ [:created_at, timestamp], [:issuer, [[:issuer_id, options[:issuer_id]]]], [:merchant, [ [:merchant_id, self.class.merchant_id], [:sub_id, @sub_id], [:authentication, AUTHENTICATION_TYPE], [:token, token], [:token_code, token_code(message)], [:merchant_return_url, options[:return_url]] ]], [:transaction, [ [:purchase_id, options[:order_id]], [:amount, money], [:currency, CURRENCY], [:expiration_period, options[:expiration_period]], [:language, LANGUAGE], [:description, options[:description]], [:entrance_code, options[:entrance_code]] ]] ]) end def log(thing, contents) $stderr.write("\n#{thing}:\n\n#{contents}\n") if $DEBUG end end end