From 80d50b83841e7f6ec7abe94572680f8c3c9e03d8 Mon Sep 17 00:00:00 2001 From: Frank Oxener Date: Tue, 23 Mar 2010 12:58:25 +0100 Subject: [PATCH] Initial commit, extraction of iDEAL gateway code from ActiveMerchant (fork made by Alloy) --- LICENSE | 2 +- README.rdoc | 17 - README.textile | 43 ++ Rakefile | 22 +- VERSION | 2 +- init.rb | 1 + lib/active_merchant_ideal.rb | 3 + lib/active_merchant_ideal/ideal.rb | 493 ++++++++++++++ lib/active_merchant_ideal/ideal_response.rb | 219 ++++++ test/fixtures.yml | 12 + test/helper.rb | 167 ++++- test/remote_ideal_test.rb | 138 ++++ test/test_active_merchant_ideal.rb | 707 +++++++++++++++++++- 13 files changed, 1787 insertions(+), 39 deletions(-) delete mode 100644 README.rdoc create mode 100644 README.textile create mode 100644 init.rb create mode 100644 lib/active_merchant_ideal/ideal.rb create mode 100644 lib/active_merchant_ideal/ideal_response.rb create mode 100644 test/fixtures.yml create mode 100644 test/remote_ideal_test.rb diff --git a/LICENSE b/LICENSE index f565a76..1ee9351 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2009 Frank Oxener +Copyright (c) 2010 Frank Oxener - Agile Dovadi B.V. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.rdoc b/README.rdoc deleted file mode 100644 index e0a3f22..0000000 --- a/README.rdoc +++ /dev/null @@ -1,17 +0,0 @@ -= active_merchant_ideal - -Description goes here. - -== Note on Patches/Pull Requests - -* Fork the project. -* Make your feature addition or bug fix. -* Add tests for it. This is important so I don't break it in a - future version unintentionally. -* Commit, do not mess with rakefile, version, or history. - (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) -* Send me a pull request. Bonus points for topic branches. - -== Copyright - -Copyright (c) 2010 Frank Oxener. See LICENSE for details. diff --git a/README.textile b/README.textile new file mode 100644 index 0000000..e1212e4 --- /dev/null +++ b/README.textile @@ -0,0 +1,43 @@ +h2. Active_merchant_ideal + +Download: http://github.com/dovadi/active_merchant_ideal or clone + +* git clone git://github.com/dovadi/active_merchant_ideal.git + +h2. Description: + +iDEAL payment gateway for ActiveMerchant (see http://www.ideal.nl and http://www.activemerchant.org/). + +h2. History: + +In 2006 an iDEAL payment library was written in Ruby for a web shop build in Rails for selling mobile phone credits. It was basically a translation of the PHP example given by the iDEAL organization (see iDEAL Advanced Integration Manual PHP). Is was released as the ideal-on-rails library (see http://dev.dovadi.com/projects/ideal). + +In 2007 this code was refactored as a patch for the ActiveMerchant library, this was mainly done by "Fingertips":http://www.fngtps.com/ for a client project. This patch was never accepted due to the fact it was too different (and maybe too obscure) from the 'normal' credit card gateways. + +In 2009 Fingertips forked the ActiveMerchant library and added an iDEAL gateway (presumable based on the first ActiveMerchant patch) to a new ideal branch. + +In 2010 this code was extracted and converted into a separate gem, so it can be more easily used in combination with the latest version of ActiveMerchant. This library is just an extraction, nothing more and nothing less. There are no fundamental changes between the code from the ideal branch and the code of this gem. + +h2. Install: + +
+As a gem:
+
+*  sudo gem install active_merchant_ideal 
+*  Add the following to your environment.rb:
+
+config.gem 'active_merchant_ideal'
+
+As a plugin:
+
+*  ./script/plugin install git://github.com/dovadi/active_merchant_ideal.git
+
+ +h2. Test + +* You can run the tests from this gem with rake test. +* For running the seven remote test transaction use rake test:remote + +h2. Credits + +This gem is maintained by "Agile Dovadi BV":http://dovadi.com, contact "Frank Oxener":mailto:frank@dovadi.com diff --git a/Rakefile b/Rakefile index f97f03f..d654a9d 100644 --- a/Rakefile +++ b/Rakefile @@ -5,11 +5,11 @@ begin require 'jeweler' Jeweler::Tasks.new do |gem| gem.name = "active_merchant_ideal" - gem.summary = %Q{TODO: one-line summary of your gem} - gem.description = %Q{TODO: longer description of your gem} + gem.summary = %Q{iDEAL gateway for ActiveMerchant} + gem.description = %Q{iDEAL payment gateway for ActiveMerchant (see http://www.ideal.nl and http://www.activemerchant.org/)} gem.email = "frank.oxener@gmail.com" gem.homepage = "http://github.com/dovadi/active_merchant_ideal" - gem.authors = ["Frank Oxener"] + gem.authors = ["Soemirno Kartosoewito, Matthijs Kadijk, Aloy Duran, Frank Oxener"] gem.add_development_dependency "thoughtbot-shoulda", ">= 0" # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings end @@ -25,16 +25,12 @@ Rake::TestTask.new(:test) do |test| test.verbose = true end -begin - require 'rcov/rcovtask' - Rcov::RcovTask.new do |test| - test.libs << 'test' - test.pattern = 'test/**/test_*.rb' - test.verbose = true - end -rescue LoadError - task :rcov do - abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" +namespace :test do + desc "Run the remote tests for iDEAL gateway" + Rake::TestTask.new(:remote) do |t| + t.libs << "test" + t.test_files = FileList['remote_ideal_test.rb'] + t.verbose = true end end diff --git a/VERSION b/VERSION index 6e8bf73..77d6f4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.0.0 diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..2c465e4 --- /dev/null +++ b/init.rb @@ -0,0 +1 @@ +require 'active_mechant_ideal' \ No newline at end of file diff --git a/lib/active_merchant_ideal.rb b/lib/active_merchant_ideal.rb index e69de29..bfe8cee 100644 --- a/lib/active_merchant_ideal.rb +++ b/lib/active_merchant_ideal.rb @@ -0,0 +1,3 @@ +require 'active_merchant' +require 'active_merchant_ideal/ideal.rb' +require 'active_merchant_ideal/ideal_response.rb' diff --git a/lib/active_merchant_ideal/ideal.rb b/lib/active_merchant_ideal/ideal.rb new file mode 100644 index 0000000..f1fc2b6 --- /dev/null +++ b/lib/active_merchant_ideal/ideal.rb @@ -0,0 +1,493 @@ +require 'openssl' +require 'net/https' +require 'base64' +require 'digest/sha1' + +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + # == iDEAL + # + # iDEAL is a set of standards developed to facilitate online payments + # through the online banking applications that most Dutch banks provide. + # + # If a consumer already has online banking with ABN AMRO, Fortis, + # ING/Postbank, Rabobank, or SNS Bank, they can make payments using iDEAL in + # a way that they are already familiar with. + # + # See http://ideal.nl and http://idealdesk.com for more information. + # + # ==== Merchant account + # + # In order to use iDEAL you will need to get an iDEAL merchant account from + # your bank. Every bank offers ‘complete payment’ services, which can + # obfuscate the right choice. The payment product that you will want to + # get, in order to use this gateway class, is a bare bones iDEAL account. + # + # * ING/Postbank: iDEAL Advanced + # * ABN AMRO: iDEAL Zelfbouw + # * Fortis: ? (Unknown) + # * Rabobank: Rabo iDEAL Professional. (Unverified) + # * SNS Bank: Not yet available. (http://www.snsbank.nl/zakelijk/betalingsverkeer/kan-ik-ideal-gebruiken-voor-mijn-webwinkel.html) + # + # At least the ING bank requires you to perform 7 remote tests which have + # to pass before you will get access to the live environment. These tests + # have been implemented in the remote tests. Running these should be enough: + # + # test/remote/remote_ideal_test.rb + # + # If you implement tests for other banks, if they require such acceptance + # tests, please do submit a patch or contact me directly: frank@dovadi.com. + # + # ==== Private keys, certificates and all that jazz + # + # Messages to, and from, the acquirer, are all signed in order to prove + # their authenticity. This means that you will have to have a certificate + # to sign your messages going to the acquirer _and_ you will need to have + # the certificate of the acquirer to verify its signed messages. + # + # The latter can be downloaded from your acquirer after registration. + # The former, however, can be a certificate signed by a CA authority or a + # self-signed certificate. + # + # To create a self-signed certificate follow these steps: + # + # $ /usr/bin/openssl genrsa -des3 -out private_key.pem -passout pass:the_passphrase 1024 + # $ /usr/bin/openssl req -x509 -new -key private_key.pem -passin pass:the_passphrase -days 3650 -out private_certificate.cer + # + # Substitute the_passphrase with your own passphrase. + # + # For more information see: + # * http://en.wikipedia.org/wiki/Certificate_authority + # * http://en.wikipedia.org/wiki/Self-signed_certificate + # + # === Example (Rails) + # + # ==== First configure the gateway + # + # Put the following code in, for instance, an initializer: + # + # IdealGateway.live_url = 'https://ideal.secure-ing.com:443/ideal/iDeal' + # + # IdealGateway.merchant_id = '00123456789' + # + # # CERTIFICATE_ROOT points to a directory where the key and certificates are located. + # IdealGateway.passphrase = 'the_private_key_passphrase' + # IdealGateway.private_key_file = File.join(CERTIFICATE_ROOT, 'private_key.pem') + # IdealGateway.private_certificate_file = File.join(CERTIFICATE_ROOT, 'private_certificate.cer') + # IdealGateway.ideal_certificate_file = File.join(CERTIFICATE_ROOT, 'ideal.cer') + # + # ==== View + # + # Give the consumer a list of available issuer options: + # + # gateway = ActiveMerchant::Billing::IdealGateway.new + # issuers = gateway.issuers.list + # sorted_issuers = issuers.sort_by { |issuer| issuer[:name] } + # select('purchase', 'issuer_id', issuers.map { |issuer| [issuer[:name], issuer[:id]] }) + # + # Could become: + # + # + # + # ==== Controller + # + # First you'll need to setup a transaction and redirect the consumer there + # so she can make the payment: + # + # class PurchasesController < ActionController::Base + # def create + # purchase = @user.purchases.build(:price => 1000) # €10.00 in cents. + # purchase.save(false) # We want an id for the URL. + # + # purchase_options = { + # :issuer_id => params[:purchase][:issuer_id], + # :order_id => purchase.id, + # :return_url => purchase_url(purchase), + # :description => 'A Dutch windmill' + # } + # + # # Save the purchase instance so that the consumer can return to its resource url to finish the transaction. + # purchase.update_attributes!(purchase_options) + # + # gateway = ActiveMerchant::Billing::IdealGateway.new + # transaction_response = gateway.setup_purchase(purchase.price, purchase_options) + # if transaction_response.success? + # + # # Store the transaction_id that the acquirer has created to identify the transaction. + # purchase.update_attributes!(:transaction_id => transaction_response.transaction_id) + # + # # Redirect the consumer to the issuer’s payment page. + # redirect_to transaction_response.service_url + # end + # end + # end + # + # After the consumer is done with the payment she will be redirected to the + # :return_url. It's now _your_ responsibility as merchant to check + # if the payment has been made: + # + # class PurchasesController < ActionController::Base + # def show + # gateway = ActiveMerchant::Billing::IdealGateway.new + # transaction_status = gateway.capture(@purchase.transaction_id) + # + # if transaction_status.success? + # @purchase.update_attributes!(:paid => true) + # flash[:notice] = "Congratulations, you are now the proud owner of a Dutch windmill!" + # end + # end + # end + # + # === Response classes + # + # * IdealResponse + # * IdealTransactionResponse + # * IdealStatusResponse + # * IdealDirectoryResponse + # + # See the IdealResponse base class for more information on errors. + class IdealGateway < Gateway + AUTHENTICATION_TYPE = 'SHA1_RSA' + LANGUAGE = 'nl' + CURRENCY = 'EUR' + API_VERSION = '1.1.0' + XML_NAMESPACE = 'http://www.idealdesk.com/Message' + + # Assigns the global iDEAL merchant id. Make sure to use a string with + # leading zeroes if needed. + cattr_accessor :merchant_id + + # Assigns the passphrase that should be used for the merchant private_key. + cattr_accessor :passphrase + + # 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 + + # Assign the test and production urls for your iDeal acquirer. + # + # For instance, for ING: + # + # ActiveMerchant::Billing::IdealGateway.test_url = "https://idealtest.secure-ing.com:443/ideal/iDeal" + # ActiveMerchant::Billing::IdealGateway.live_url = "https://ideal.secure-ing.com:443/ideal/iDeal" + cattr_accessor :test_url, :live_url + + # Returns the merchant `subID' being used for this IdealGateway instance. + # Defaults to 0. + attr_reader :sub_id + + # Initializes a new IdealGateway instance. + # + # You can optionally specify :sub_id. Defaults to 0. + def initialize(options = {}) + @sub_id = options[:sub_id] || 0 + super + end + + # Returns the url of the acquirer matching the current environment. + # + # When #test? returns +true+ the IdealGateway.test_url is used, otherwise + # the IdealGateway.live_url is used. + def acquirer_url + test? ? self.class.test_url : self.class.live_url + end + + # Sends a directory request to the acquirer and returns an + # IdealDirectoryResponse. Use IdealDirectoryResponse#list to receive the + # actuall array of available issuers. + # + # gateway.issuers.list # => [{ :id => '1006', :name => 'ABN AMRO Bank' }, …] + def issuers + post_data build_directory_request_body, IdealDirectoryResponse + end + + # Starts a purchase by sending an acquirer transaction request for the + # specified +money+ amount in EURO cents. + # + # On success returns an IdealTransactionResponse 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 IdealStatusResponse#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 IdealGateway class description for a more elaborate example. + def setup_purchase(money, options) + post_data build_transaction_request_body(money, options), IdealTransactionResponse + end + + # Sends a acquirer status request for the specified +transaction_id+ and + # returns an IdealStatusResponse. + # + # 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 IdealGateway class description for a more elaborate example. + def capture(transaction_id) + post_data build_status_request_body(:transaction_id => transaction_id), IdealStatusResponse + end + + private + + def post_data(data, response_klass) + response_klass.new(ssl_post(acquirer_url, data), :test => 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 ensure_validity(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 IdealStatusResponse instance. + def token + Digest::SHA1.hexdigest(self.class.private_certificate.to_der).upcase + end + + # Creates a +tokenCode+ from the specified +message+. + def token_code(message) + signature = self.class.private_key.sign(OpenSSL::Digest::SHA1.new, message.gsub(/\s/m, '')) + Base64.encode64(signature).gsub(/\s/m, '') + 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 + + # 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] + key.camelize[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 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) + + ensure_validity(:money, money.to_s, 12) + ensure_validity(:order_id, options[:order_id], 12) + ensure_validity(:description, options[:description], 32) + ensure_validity(: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 + + end + end +end \ No newline at end of file diff --git a/lib/active_merchant_ideal/ideal_response.rb b/lib/active_merchant_ideal/ideal_response.rb new file mode 100644 index 0000000..e735dc5 --- /dev/null +++ b/lib/active_merchant_ideal/ideal_response.rb @@ -0,0 +1,219 @@ +require 'openssl' +require 'base64' +require 'rexml/document' + +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + # The base class for all iDEAL response classes. + # + # Note that if the iDEAL system is under load it will _not_ allow more + # then two retries per request. + class IdealResponse < Response + def initialize(response_body, options = {}) + @response = REXML::Document.new(response_body).root + @success = !error_occured? + @test = options[:test] + end + + # Returns a technical error message. + def error_message + text('//Error/errorMessage') unless success? + end + + # Returns a consumer friendly error message. + def consumer_error_message + text('//Error/consumerMessage') unless success? + end + + # Returns details on the error if available. + def error_details + text('//Error/errorDetail') unless success? + end + + # Returns an error type inflected from the first two characters of the + # error code. See error_code for a full list of errors. + # + # Error code to type mappings: + # + # * +IX+ - :xml + # * +SO+ - :system + # * +SE+ - :security + # * +BR+ - :value + # * +AP+ - :application + def error_type + unless success? + case error_code[0,2] + when 'IX' then :xml + when 'SO' then :system + when 'SE' then :security + when 'BR' then :value + when 'AP' then :application + end + end + end + + # Returns the code of the error that occured. + # + # === Codes + # + # ==== IX: Invalid XML and all related problems + # + # Such as incorrect encoding, invalid version, or otherwise unreadable: + # + # * IX1000 - Received XML not well-formed. + # * IX1100 - Received XML not valid. + # * IX1200 - Encoding type not UTF-8. + # * IX1300 - XML version number invalid. + # * IX1400 - Unknown message. + # * IX1500 - Mandatory main value missing. (Merchant ID ?) + # * IX1600 - Mandatory value missing. + # + # ==== SO: System maintenance or failure + # + # The errors that are communicated in the event of system maintenance or + # system failure. Also covers the situation where new requests are no + # longer being accepted but requests already submitted will be dealt with + # (until a certain time): + # + # * SO1000 - Failure in system. + # * SO1200 - System busy. Try again later. + # * SO1400 - Unavailable due to maintenance. + # + # ==== SE: Security and authentication errors + # + # Incorrect authentication methods and expired certificates: + # + # * SE2000 - Authentication error. + # * SE2100 - Authentication method not supported. + # * SE2700 - Invalid electronic signature. + # + # ==== BR: Field errors + # + # Extra information on incorrect fields: + # + # * BR1200 - iDEAL version number invalid. + # * BR1210 - Value contains non-permitted character. + # * BR1220 - Value too long. + # * BR1230 - Value too short. + # * BR1240 - Value too high. + # * BR1250 - Value too low. + # * BR1250 - Unknown entry in list. + # * BR1270 - Invalid date/time. + # * BR1280 - Invalid URL. + # + # ==== AP: Application errors + # + # Errors relating to IDs, account numbers, time zones, transactions: + # + # * AP1000 - Acquirer ID unknown. + # * AP1100 - Merchant ID unknown. + # * AP1200 - Issuer ID unknown. + # * AP1300 - Sub ID unknown. + # * AP1500 - Merchant ID not active. + # * AP2600 - Transaction does not exist. + # * AP2620 - Transaction already submitted. + # * AP2700 - Bank account number not 11-proof. + # * AP2900 - Selected currency not supported. + # * AP2910 - Maximum amount exceeded. (Detailed record states the maximum amount). + # * AP2915 - Amount too low. (Detailed record states the minimum amount). + # * AP2920 - Please adjust expiration period. See suggested expiration period. + def error_code + text('//errorCode') unless success? + end + + private + + def error_occured? + @response.name == 'ErrorRes' + end + + def text(path) + @response.get_text(path).to_s + end + end + + # An instance of IdealTransactionResponse is returned from + # IdealGateway#setup_purchase which returns the service_url to where the + # user should be redirected to perform the transaction _and_ the + # transaction ID. + class IdealTransactionResponse < IdealResponse + # Returns the URL to the issuer’s page where the consumer should be + # redirected to in order to perform the payment. + def service_url + text('//issuerAuthenticationURL') + end + + # Returns the transaction ID which is needed for requesting the status + # of a transaction. See IdealGateway#capture. + def transaction_id + text('//transactionID') + end + + # Returns the :order_id for this transaction. + def order_id + text('//purchaseID') + end + end + + # An instance of IdealStatusResponse is returned from IdealGateway#capture + # which returns whether or not the transaction that was started with + # IdealGateway#setup_purchase was successful. + # + # It takes care of checking if the message was authentic by verifying the + # the message and its signature against the iDEAL certificate. + # + # If success? returns +false+ because the authenticity wasn't verified + # there will be no error_code, error_message, and error_type. Use verified? + # to check if the authenticity has been verified. + class IdealStatusResponse < IdealResponse + def initialize(response_body, options = {}) + super + @success = transaction_successful? + end + + # Returns the status message, which is one of: :success, + # :cancelled, :expired, :open, or + # :failure. + def status + text('//status').downcase.to_sym + end + + # Returns whether or not the authenticity of the message could be + # verified. + def verified? + @verified ||= IdealGateway.ideal_certificate.public_key. + verify(OpenSSL::Digest::SHA1.new, signature, message) + end + + private + + # Checks if no errors occured _and_ if the message was authentic. + def transaction_successful? + !error_occured? && status == :success && verified? + end + + # The message that we need to verify the authenticity. + def message + text('//createDateTimeStamp') + text('//transactionID') + text('//status') + text('//consumerAccountNumber') + end + + def signature + Base64.decode64(text('//signatureValue')) + end + end + + # An instance of IdealDirectoryResponse is returned from + # IdealGateway#issuers which returns the list of issuers available at the + # acquirer. + class IdealDirectoryResponse < IdealResponse + # Returns a list of issuers available at the acquirer. + # + # gateway.issuers.list # => [{ :id => '1006', :name => 'ABN AMRO Bank' }] + def list + @response.get_elements('//Issuer').map do |issuer| + { :id => issuer.get_text('issuerID').to_s, :name => issuer.get_text('issuerName').to_s } + end + end + end + end +end \ No newline at end of file diff --git a/test/fixtures.yml b/test/fixtures.yml new file mode 100644 index 0000000..ccea5ff --- /dev/null +++ b/test/fixtures.yml @@ -0,0 +1,12 @@ +# You can also paste the contents of the key and certificates here, +# if you want to do so remove the “_file” part of the keys. +ideal_ing_postbank: + test_url: https://idealtest.secure-ing.com:443/ideal/iDeal + merchant_id: ID + passphrase: PRIVATE KEY PASSPHRASE + private_key_file: |-- + PASTE THE PATH TO YOUR PEM FILE HERE + private_certificate_file: |-- + PASTE THE PATH TO YOUR CERTIFICATE FILE HERE + ideal_certificate_file: |-- + PASTE THE PATH TO THE iDEAL CERTIFICATE FILE HERE \ No newline at end of file diff --git a/test/helper.rb b/test/helper.rb index 05cc9a7..094865a 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,10 +1,173 @@ require 'rubygems' require 'test/unit' -require 'shoulda' +require 'active_support' +require 'mocha' $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) $LOAD_PATH.unshift(File.dirname(__FILE__)) require 'active_merchant_ideal' -class Test::Unit::TestCase +ActiveMerchant::Billing::Base.mode = :test + +# Test gateways +class SimpleTestGateway < ActiveMerchant::Billing::Gateway +end + +class SubclassGateway < SimpleTestGateway +end + + +module ActiveMerchant + module Assertions + def assert_field(field, value) + clean_backtrace do + assert_equal value, @helper.fields[field] + end + end + + # Allows the testing of you to check for negative assertions: + # + # # Instead of + # assert !something_that_is_false + # + # # Do this + # assert_false something_that_should_be_false + # + # An optional +msg+ parameter is available to help you debug. + def assert_false(boolean, message = nil) + message = build_message message, ' is not false or nil.', boolean + + clean_backtrace do + assert_block message do + not boolean + end + end + end + + # A handy little assertion to check for a successful response: + # + # # Instead of + # assert response.success? + # + # # DRY that up with + # assert_success response + # + # A message will automatically show the inspection of the response + # object if things go afoul. + def assert_success(response) + clean_backtrace do + assert response.success?, "Response failed: #{response.inspect}" + end + end + + # The negative of +assert_success+ + def assert_failure(response) + clean_backtrace do + assert_false response.success?, "Response expected to fail: #{response.inspect}" + end + end + + def assert_valid(validateable) + clean_backtrace do + assert validateable.valid?, "Expected to be valid" + end + end + + def assert_not_valid(validateable) + clean_backtrace do + assert_false validateable.valid?, "Expected to not be valid" + end + end + + private + def clean_backtrace(&block) + yield + rescue Test::Unit::AssertionFailedError => e + path = File.expand_path(__FILE__) + raise Test::Unit::AssertionFailedError, e.message, e.backtrace.reject { |line| File.expand_path(line) =~ /#{path}/ } + end + end +end + + +module Test + module Unit + class TestCase + HOME_DIR = RUBY_PLATFORM =~ /mswin32/ ? ENV['HOMEPATH'] : ENV['HOME'] unless defined?(HOME_DIR) + LOCAL_CREDENTIALS = File.join(HOME_DIR.to_s, '.active_merchant/fixtures.yml') unless defined?(LOCAL_CREDENTIALS) + DEFAULT_CREDENTIALS = File.join(File.dirname(__FILE__), 'fixtures.yml') unless defined?(DEFAULT_CREDENTIALS) + + include ActiveMerchant::Billing + include ActiveMerchant::Assertions + include ActiveMerchant::Utils + + private + def credit_card(number = '4242424242424242', options = {}) + defaults = { + :number => number, + :month => 9, + :year => Time.now.year + 1, + :first_name => 'Longbob', + :last_name => 'Longsen', + :verification_value => '123', + :type => 'visa' + }.update(options) + + CreditCard.new(defaults) + end + + def check(options = {}) + defaults = { + :name => 'Jim Smith', + :routing_number => '244183602', + :account_number => '15378535', + :account_holder_type => 'personal', + :account_type => 'checking', + :number => '1' + }.update(options) + + Check.new(defaults) + end + + def address(options = {}) + { + :name => 'Jim Smith', + :address1 => '1234 My Street', + :address2 => 'Apt 1', + :company => 'Widgets Inc', + :city => 'Ottawa', + :state => 'ON', + :zip => 'K1C2N6', + :country => 'CA', + :phone => '(555)555-5555', + :fax => '(555)555-6666' + }.update(options) + end + + def all_fixtures + @@fixtures ||= load_fixtures + end + + def fixtures(key) + data = all_fixtures[key] || raise(StandardError, "No fixture data was found for '#{key}'") + + data.dup + end + + def load_fixtures + file = File.exists?(LOCAL_CREDENTIALS) ? LOCAL_CREDENTIALS : DEFAULT_CREDENTIALS + yaml_data = YAML.load(File.read(file)) + symbolize_keys(yaml_data) + + yaml_data + end + + def symbolize_keys(hash) + return unless hash.is_a?(Hash) + + hash.symbolize_keys! + hash.each{|k,v| symbolize_keys(v)} + end + end + end end diff --git a/test/remote_ideal_test.rb b/test/remote_ideal_test.rb new file mode 100644 index 0000000..37afdd2 --- /dev/null +++ b/test/remote_ideal_test.rb @@ -0,0 +1,138 @@ +require File.dirname(__FILE__) + '/helper' + +class IdealTest < Test::Unit::TestCase + def setup + Base.mode = :test + setup_ideal_gateway(fixtures(:ideal_ing_postbank)) + + @gateway = IdealGateway.new + + @valid_options = { + :issuer_id => '0151', + :expiration_period => 'PT10M', + :return_url => 'http://return_to.example.com', + :order_id => '123456789012', + :currency => 'EUR', + :description => 'A classic Dutch windmill', + :entrance_code => '1234' + } + end + + def test_making_test_requests + assert @gateway.issuers.test? + end + + def test_setup_purchase_with_valid_options + response = @gateway.setup_purchase(550, @valid_options) + + assert_success response + assert_not_nil response.service_url + assert_not_nil response.transaction_id + assert_equal @valid_options[:order_id], response.order_id + end + + def test_setup_purchase_with_invalid_amount + response = @gateway.setup_purchase(0.5, @valid_options) + + assert_failure response + assert_equal "BR1210", response.error_code + assert_not_nil response.error_message + assert_not_nil response.consumer_error_message + end + + # TODO: Should we raise a SecurityError instead of setting success to false? + def test_status_response_with_invalid_signature + IdealStatusResponse.any_instance.stubs(:signature).returns('db82/jpJRvKQKoiDvu33X0yoDAQpayJOaW2Y8zbR1qk1i3epvTXi+6g+QVBY93YzGv4w+Va+vL3uNmzyRjYsm2309d1CWFVsn5Mk24NLSvhYfwVHEpznyMqizALEVUNSoiSHRkZUDfXowBAyLT/tQVGbuUuBj+TKblY826nRa7U=') + response = capture_transaction(:success) + + assert_failure response + assert !response.verified? + end + + ### + # + # These are the 7 integration tests of ING which need to be ran sucessfuly + # _before_ you'll get access to the live environment. + # + # See test_transaction_id for info on how the remote tests are ran. + # + + def test_retrieval_of_issuers + assert_equal [{ :id => '0151', :name => 'Issuer Simulator' }], @gateway.issuers.list + end + + def test_successful_transaction + assert_success capture_transaction(:success) + end + + def test_cancelled_transaction + captured_response = capture_transaction(:cancelled) + + assert_failure captured_response + assert_equal :cancelled, captured_response.status + end + + def test_expired_transaction + captured_response = capture_transaction(:expired) + + assert_failure captured_response + assert_equal :expired, captured_response.status + end + + def test_still_open_transaction + captured_response = capture_transaction(:open) + + assert_failure captured_response + assert_equal :open, captured_response.status + end + + def test_failed_transaction + captured_response = capture_transaction(:failure) + + assert_failure captured_response + assert_equal :failure, captured_response.status + end + + def test_internal_server_error + captured_response = capture_transaction(:server_error) + + assert_failure captured_response + assert_equal 'SO1000', captured_response.error_code + end + + private + + # Shortcut method which does a #setup_purchase through #test_transaction and + # captures the resulting transaction and returns the capture response. + def capture_transaction(type) + @gateway.capture test_transaction(type).transaction_id + end + + # Calls #setup_purchase with the amount corresponding to the named test and + # returns the response. Before returning an assertion will be ran to test + # whether or not the transaction was successful. + def test_transaction(type) + amount = case type + when :success then 100 + when :cancelled then 200 + when :expired then 300 + when :open then 400 + when :failure then 500 + when :server_error then 700 + end + + response = @gateway.setup_purchase(amount, @valid_options) + assert response.success? + response + end + + # Setup the gateway by providing a hash of aatributes and values. + def setup_ideal_gateway(fixture) + fixture = fixture.dup + if passphrase = fixture.delete(:passphrase) + IdealGateway.passphrase = passphrase + end + fixture.each { |key, value| IdealGateway.send("#{key}=", value) } + IdealGateway.live_url = nil + end +end \ No newline at end of file diff --git a/test/test_active_merchant_ideal.rb b/test/test_active_merchant_ideal.rb index 16cb9df..f7bd831 100644 --- a/test/test_active_merchant_ideal.rb +++ b/test/test_active_merchant_ideal.rb @@ -1,7 +1,704 @@ -require 'helper' +require File.dirname(__FILE__) + '/helper' -class TestActiveMerchantIdeal < Test::Unit::TestCase - should "probably rename this file and start testing for real" do - flunk "hey buddy, you should probably rename this file and start testing for real" +module IdealTestCases + # This method is called at the end of the file when all fixture data has been loaded. + def self.setup_ideal_gateway! + ActiveMerchant::Billing::IdealGateway.class_eval do + self.merchant_id = '123456789' + + self.passphrase = 'passphrase' + self.private_key = PRIVATE_KEY + self.private_certificate = PRIVATE_CERTIFICATE + self.ideal_certificate = IDEAL_CERTIFICATE + + self.test_url = "https://idealtest.example.com:443/ideal/iDeal" + self.live_url = "https://ideal.example.com:443/ideal/iDeal" + end + end + + VALID_PURCHASE_OPTIONS = { + :issuer_id => '0001', + :expiration_period => 'PT10M', + :return_url => 'http://return_to.example.com', + :order_id => '12345678901', + :description => 'A classic Dutch windmill', + :entrance_code => '1234' + } + + ### + # + # Actual test cases + # + + class ClassMethodsTest < Test::Unit::TestCase + def test_merchant_id + assert_equal IdealGateway.merchant_id, '123456789' + end + + def test_private_certificate_returns_a_loaded_Certificate_instance + assert_equal IdealGateway.private_certificate.to_text, + OpenSSL::X509::Certificate.new(PRIVATE_CERTIFICATE).to_text + end + + def test_private_key_returns_a_loaded_PKey_RSA_instance + assert_equal IdealGateway.private_key.to_text, + OpenSSL::PKey::RSA.new(PRIVATE_KEY, IdealGateway.passphrase).to_text + end + + def test_ideal_certificate_returns_a_loaded_Certificate_instance + assert_equal IdealGateway.ideal_certificate.to_text, + OpenSSL::X509::Certificate.new(IDEAL_CERTIFICATE).to_text + end + end + + class GeneralTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + end + + def test_optional_initialization_options + assert_equal 0, IdealGateway.new.sub_id + assert_equal 1, IdealGateway.new(:sub_id => 1).sub_id + end + + def test_returns_the_test_url_when_in_the_test_env + @gateway.stubs(:test?).returns(true) + assert_equal IdealGateway.test_url, @gateway.send(:acquirer_url) + end + + def test_returns_the_live_url_when_not_in_the_test_env + @gateway.stubs(:test?).returns(false) + assert_equal IdealGateway.live_url, @gateway.send(:acquirer_url) + end + + def test_returns_created_at_timestamp + timestamp = '2001-12-17T09:30:47.000Z' + Time.any_instance.stubs(:gmtime).returns(DateTime.parse(timestamp)) + + assert_equal timestamp, @gateway.send(:created_at_timestamp) + end + + def test_ruby_to_java_keys_conversion + keys = [ + [:acquirer_transaction_request, 'AcquirerTrxReq'], + [:acquirer_status_request, 'AcquirerStatusReq'], + [:directory_request, 'DirectoryReq'], + [:created_at, 'createDateTimeStamp'], + [:issuer, 'Issuer'], + [:merchant, 'Merchant'], + [:transaction, 'Transaction'], + [:issuer_id, 'issuerID'], + [:merchant_id, 'merchantID'], + [:sub_id, 'subID'], + [:token_code, 'tokenCode'], + [:merchant_return_url, 'merchantReturnURL'], + [:purchase_id, 'purchaseID'], + [:expiration_period, 'expirationPeriod'], + [:entrance_code, 'entranceCode'] + ] + + keys.each do |key, expected_key| + assert_equal expected_key, @gateway.send(:javaize_key, key) + end + end + + def test_does_not_convert_unknown_key_to_java_key + assert_equal 'not_a_registered_key', @gateway.send(:javaize_key, :not_a_registered_key) + end + + def test_token_generation + expected_token = Digest::SHA1.hexdigest(OpenSSL::X509::Certificate.new(PRIVATE_CERTIFICATE).to_der).upcase + assert_equal expected_token, @gateway.send(:token) + end + + def test_token_code_generation + message = "Top\tsecret\tman.\nI could tell you, but then I'd have to kill you…" + stripped_message = message.gsub(/\s/m, '') + + sha1 = OpenSSL::Digest::SHA1.new + OpenSSL::Digest::SHA1.stubs(:new).returns(sha1) + + signature = IdealGateway.private_key.sign(sha1, stripped_message) + encoded_signature = Base64.encode64(signature).strip.gsub(/\n/, '') + + assert_equal encoded_signature, @gateway.send(:token_code, message) + end + + def test_posts_data_with_ssl_to_acquirer_url_and_return_the_correct_response + IdealResponse.expects(:new).with('response', :test => true) + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'data').returns('response') + @gateway.send(:post_data, 'data', IdealResponse) + + @gateway.stubs(:test?).returns(false) + IdealResponse.expects(:new).with('response', :test => false) + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'data').returns('response') + @gateway.send(:post_data, 'data', IdealResponse) + end end -end + + class XMLBuildingTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + end + + def test_contains_correct_info_in_root_node + expected_xml = Builder::XmlMarkup.new + expected_xml.instruct! + expected_xml.tag!('AcquirerTrxReq', 'xmlns' => IdealGateway::XML_NAMESPACE, 'version' => IdealGateway::API_VERSION) {} + + assert_equal expected_xml.target!, @gateway.send(:xml_for, :acquirer_transaction_request, []) + end + + def test_creates_correct_xml_with_java_keys_from_array_with_ruby_keys + expected_xml = Builder::XmlMarkup.new + expected_xml.instruct! + expected_xml.tag!('AcquirerTrxReq', 'xmlns' => IdealGateway::XML_NAMESPACE, 'version' => IdealGateway::API_VERSION) do + expected_xml.tag!('a_parent') do + expected_xml.tag!('createDateTimeStamp', '2009-01-26') + end + end + + assert_equal expected_xml.target!, @gateway.send(:xml_for, :acquirer_transaction_request, [[:a_parent, [[:created_at, '2009-01-26']]]]) + end + end + + class RequestBodyBuildingTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + + @gateway.stubs(:created_at_timestamp).returns('created_at_timestamp') + @gateway.stubs(:token).returns('the_token') + @gateway.stubs(:token_code) + + @transaction_id = '0001023456789112' + end + + def test_build_transaction_request_body_raises_ArgumentError_with_missing_required_options + options = VALID_PURCHASE_OPTIONS.dup + options.keys.each do |key| + options.delete(key) + + assert_raise(ArgumentError) do + @gateway.send(:build_transaction_request_body, 100, options) + end + end + end + + def test_valid_with_valid_options + assert_not_nil @gateway.send(:build_transaction_request_body, 4321, VALID_PURCHASE_OPTIONS) + end + + def test_checks_that_fields_are_not_too_long + assert_raise ArgumentError do + @gateway.send(:build_transaction_request_body, 1234567890123, VALID_PURCHASE_OPTIONS) # 13 chars + end + + [ + [:order_id, '12345678901234567'], # 17 chars, + [:description, '123456789012345678901234567890123'], # 33 chars + [:entrance_code, '12345678901234567890123456789012345678901'] # 41 + ].each do |key, value| + options = VALID_PURCHASE_OPTIONS.dup + options[key] = value + + assert_raise ArgumentError do + @gateway.send(:build_transaction_request_body, 4321, options) + end + end + end + + def test_checks_that_fields_do_not_contain_diacritical_characters + assert_raise ArgumentError do + @gateway.send(:build_transaction_request_body, 'graphème', VALID_PURCHASE_OPTIONS) + end + + [:order_id, :description, :entrance_code].each do |key, value| + options = VALID_PURCHASE_OPTIONS.dup + options[key] = 'graphème' + + assert_raise ArgumentError do + @gateway.send(:build_transaction_request_body, 4321, options) + end + end + end + + def test_builds_a_transaction_request_body + money = 4321 + + message = 'created_at_timestamp' + + VALID_PURCHASE_OPTIONS[:issuer_id] + + IdealGateway.merchant_id + + @gateway.sub_id.to_s + + VALID_PURCHASE_OPTIONS[:return_url] + + VALID_PURCHASE_OPTIONS[:order_id] + + money.to_s + + IdealGateway::CURRENCY + + IdealGateway::LANGUAGE + + VALID_PURCHASE_OPTIONS[:description] + + VALID_PURCHASE_OPTIONS[:entrance_code] + + @gateway.expects(:token_code).with(message).returns('the_token_code') + + @gateway.expects(:xml_for).with(:acquirer_transaction_request, [ + [:created_at, 'created_at_timestamp'], + [:issuer, [[:issuer_id, VALID_PURCHASE_OPTIONS[:issuer_id]]]], + + [:merchant, [ + [:merchant_id, IdealGateway.merchant_id], + [:sub_id, @gateway.sub_id], + [:authentication, IdealGateway::AUTHENTICATION_TYPE], + [:token, 'the_token'], + [:token_code, 'the_token_code'], + [:merchant_return_url, VALID_PURCHASE_OPTIONS[:return_url]] + ]], + + [:transaction, [ + [:purchase_id, VALID_PURCHASE_OPTIONS[:order_id]], + [:amount, money], + [:currency, IdealGateway::CURRENCY], + [:expiration_period, VALID_PURCHASE_OPTIONS[:expiration_period]], + [:language, IdealGateway::LANGUAGE], + [:description, VALID_PURCHASE_OPTIONS[:description]], + [:entrance_code, VALID_PURCHASE_OPTIONS[:entrance_code]] + ]] + ]) + + @gateway.send(:build_transaction_request_body, money, VALID_PURCHASE_OPTIONS) + end + + def test_builds_a_directory_request_body + message = 'created_at_timestamp' + IdealGateway.merchant_id + @gateway.sub_id.to_s + @gateway.expects(:token_code).with(message).returns('the_token_code') + + @gateway.expects(:xml_for).with(:directory_request, [ + [:created_at, 'created_at_timestamp'], + [:merchant, [ + [:merchant_id, IdealGateway.merchant_id], + [:sub_id, @gateway.sub_id], + [:authentication, IdealGateway::AUTHENTICATION_TYPE], + [:token, 'the_token'], + [:token_code, 'the_token_code'] + ]] + ]) + + @gateway.send(:build_directory_request_body) + end + + def test_builds_a_status_request_body_raises_ArgumentError_with_missing_required_options + assert_raise(ArgumentError) do + @gateway.send(:build_status_request_body, {}) + end + end + + def test_builds_a_status_request_body + options = { :transaction_id => @transaction_id } + + message = 'created_at_timestamp' + IdealGateway.merchant_id + @gateway.sub_id.to_s + options[:transaction_id] + @gateway.expects(:token_code).with(message).returns('the_token_code') + + @gateway.expects(:xml_for).with(:acquirer_status_request, [ + [:created_at, 'created_at_timestamp'], + [:merchant, [ + [:merchant_id, IdealGateway.merchant_id], + [:sub_id, @gateway.sub_id], + [:authentication, IdealGateway::AUTHENTICATION_TYPE], + [:token, 'the_token'], + [:token_code, 'the_token_code'] + ]], + + [:transaction, [ + [:transaction_id, options[:transaction_id]] + ]], + ]) + + @gateway.send(:build_status_request_body, options) + end + end + + class GeneralResponseTest < Test::Unit::TestCase + def test_resturns_if_it_is_a_test_request + assert IdealResponse.new(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS, :test => true).test? + + assert !IdealResponse.new(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS, :test => false).test? + assert !IdealResponse.new(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS).test? + end + end + + class SuccessfulResponseTest < Test::Unit::TestCase + def setup + @response = IdealResponse.new(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS) + end + + def test_initializes_with_only_response_body + assert_equal REXML::Document.new(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS).root.to_s, + @response.instance_variable_get(:@response).to_s + end + + def test_successful + assert @response.success? + end + + def test_returns_no_error_messages + assert_nil @response.error_message + end + + def test_returns_no_error_code + assert_nil @response.error_code + end + end + + class ErrorResponseTest < Test::Unit::TestCase + def setup + @response = IdealResponse.new(ERROR_RESPONSE) + end + + def test_unsuccessful + assert !@response.success? + end + + def test_returns_error_messages + assert_equal 'Failure in system', @response.error_message + assert_equal 'System generating error: issuer', @response.error_details + assert_equal 'Betalen met iDEAL is nu niet mogelijk.', @response.consumer_error_message + end + + def test_returns_error_code + assert_equal 'SO1000', @response.error_code + end + + def test_returns_error_type + [ + ['IX1000', :xml], + ['SO1000', :system], + ['SE2000', :security], + ['BR1200', :value], + ['AP1000', :application] + ].each do |code, type| + @response.stubs(:error_code).returns(code) + assert_equal type, @response.error_type + end + end + end + + class DirectoryTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + end + + def test_returns_a_list_with_only_one_issuer + @gateway.stubs(:build_directory_request_body).returns('the request body') + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'the request body').returns(DIRECTORY_RESPONSE_WITH_ONE_ISSUER) + + expected_issuers = [{ :id => '1006', :name => 'ABN AMRO Bank' }] + + directory_response = @gateway.issuers + assert_instance_of IdealDirectoryResponse, directory_response + assert_equal expected_issuers, directory_response.list + end + + def test_returns_list_of_issuers_from_response + @gateway.stubs(:build_directory_request_body).returns('the request body') + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'the request body').returns(DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS) + + expected_issuers = [ + { :id => '1006', :name => 'ABN AMRO Bank' }, + { :id => '1003', :name => 'Postbank' }, + { :id => '1005', :name => 'Rabobank' }, + { :id => '1017', :name => 'Asr bank' }, + { :id => '1023', :name => 'Van Lanschot' } + ] + + directory_response = @gateway.issuers + assert_instance_of IdealDirectoryResponse, directory_response + assert_equal expected_issuers, directory_response.list + end + end + + class SetupPurchaseTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + + @gateway.stubs(:build_transaction_request_body).with(4321, VALID_PURCHASE_OPTIONS).returns('the request body') + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'the request body').returns(ACQUIRER_TRANSACTION_RESPONSE) + + @setup_purchase_response = @gateway.setup_purchase(4321, VALID_PURCHASE_OPTIONS) + end + + def test_setup_purchase_returns_IdealTransactionResponse + assert_instance_of IdealTransactionResponse, @setup_purchase_response + end + + def test_setup_purchase_returns_response_with_service_url + assert_equal 'https://ideal.example.com/long_service_url', @setup_purchase_response.service_url + end + + def test_setup_purchase_returns_response_with_transaction_and_order_ids + assert_equal '0001023456789112', @setup_purchase_response.transaction_id + assert_equal 'iDEAL-aankoop 21', @setup_purchase_response.order_id + end + end + + class CapturePurchaseTest < Test::Unit::TestCase + def setup + @gateway = IdealGateway.new + + @gateway.stubs(:build_status_request_body). + with(:transaction_id => '0001023456789112').returns('the request body') + end + + def test_setup_purchase_returns_IdealStatusResponse + expects_request_and_returns ACQUIRER_SUCCEEDED_STATUS_RESPONSE + assert_instance_of IdealStatusResponse, @gateway.capture('0001023456789112') + end + + # Because we don't have a real private key and certificate we stub + # verified? to return true. However, this is properly tested in the remote + # tests. + def test_capture_of_successful_payment + IdealStatusResponse.any_instance.stubs(:verified?).returns(true) + + expects_request_and_returns ACQUIRER_SUCCEEDED_STATUS_RESPONSE + capture_response = @gateway.capture('0001023456789112') + + assert capture_response.success? + end + + def test_capture_of_failed_payment + expects_request_and_returns ACQUIRER_FAILED_STATUS_RESPONSE + capture_response = @gateway.capture('0001023456789112') + + assert !capture_response.success? + end + + def test_capture_of_successful_payment_but_message_does_not_match_signature + expects_request_and_returns ACQUIRER_SUCCEEDED_BUT_WRONG_SIGNATURE_STATUS_RESPONSE + capture_response = @gateway.capture('0001023456789112') + + assert !capture_response.success? + end + + def test_returns_status + response = IdealStatusResponse.new(ACQUIRER_SUCCEEDED_STATUS_RESPONSE) + [ + ['Success', :success], + ['Cancelled', :cancelled], + ['Expired', :expired], + ['Open', :open], + ['Failure', :failure] + ].each do |raw_status, expected_status| + response.stubs(:text).with("//status").returns(raw_status) + assert_equal expected_status, response.status + end + end + + private + + def expects_request_and_returns(str) + @gateway.expects(:ssl_post).with(@gateway.acquirer_url, 'the request body').returns(str) + end + end + + ### + # + # Fixture data + # + + PRIVATE_CERTIFICATE = %{-----BEGIN CERTIFICATE----- +MIIC+zCCAmSgAwIBAgIJALVAygHjnd8ZMA0GCSqGSIb3DQEBBQUAMF0xCzAJBgNV +BAYTAk5MMRYwFAYDVQQIEw1Ob29yZC1Ib2xsYW5kMRIwEAYDVQQHEwlBbXN0ZXJk +YW0xIjAgBgNVBAoTGWlERUFMIEFjdGl2ZU1lcmNoYW50IFRlc3QwHhcNMDkwMTMw +MTMxNzQ5WhcNMjQxMjExMDM1MjI5WjBdMQswCQYDVQQGEwJOTDEWMBQGA1UECBMN +Tm9vcmQtSG9sbGFuZDESMBAGA1UEBxMJQW1zdGVyZGFtMSIwIAYDVQQKExlpREVB +TCBBY3RpdmVNZXJjaGFudCBUZXN0MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDmBpi+RVvZBA01kdP5lV5bDzu6Jp1zy78qhxxwlG8WMdUh0Qtg0kkYmeThFPoh +2c3BYuFQ+AA6f1R0Spb+hTNrBxkZaRnHCfMMD9LXquFjJ/lvSGnwkjvBmGzyTPZ1 +LIunpejm8hH0MJPqpp5AIeXjp1mv7BXA9y0FqObrrLAPaQIDAQABo4HCMIG/MB0G +A1UdDgQWBBTLqGWJt5+Ri6vrOpqGZhINbRtXczCBjwYDVR0jBIGHMIGEgBTLqGWJ +t5+Ri6vrOpqGZhINbRtXc6FhpF8wXTELMAkGA1UEBhMCTkwxFjAUBgNVBAgTDU5v +b3JkLUhvbGxhbmQxEjAQBgNVBAcTCUFtc3RlcmRhbTEiMCAGA1UEChMZaURFQUwg +QWN0aXZlTWVyY2hhbnQgVGVzdIIJALVAygHjnd8ZMAwGA1UdEwQFMAMBAf8wDQYJ +KoZIhvcNAQEFBQADgYEAGtgkmME9tgaxJIU3T7v1/xbKr6A/iwmt3sCmfJEl4Pty +aUGaHFy1KB7xmkna8gomxMWL2zZkdv4t1iGeuVCl9n77SL3MzapotdeNNqahblcN +RBshYCpWpsQQPF45/R5Xp7rXWWsjxgip7qTBNpgTx+Z/VKQpuQsFjYCYq4UCf2Y= +-----END CERTIFICATE-----} + + PRIVATE_KEY = %{-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDmBpi+RVvZBA01kdP5lV5bDzu6Jp1zy78qhxxwlG8WMdUh0Qtg +0kkYmeThFPoh2c3BYuFQ+AA6f1R0Spb+hTNrBxkZaRnHCfMMD9LXquFjJ/lvSGnw +kjvBmGzyTPZ1LIunpejm8hH0MJPqpp5AIeXjp1mv7BXA9y0FqObrrLAPaQIDAQAB +AoGAfkccz0ewVoDc5424+wk/FWpVdaoBQjKWLbiiqkMygNK2mKv0PSD0M+c4OUCU +2MSDKikoXJTpOzPvny/bmLpzMMGn9YJiWEQ5WdaTdppffdylfGPBZXZkt5M9nxJA +NL3fPT79R79mkCF8cgNUbLtNL4woSoFKwRHDU2CGvtTbxqkCQQD+TY1sGJv1VTQi +MYYx3FlEOqw3jp/2q7QluTDDGmvmVOSFnAPfmX0rKEtnBmG4ID7IaG+IQFthDudL +3trqGQdTAkEA54+RxyCZiXDfkh23cD0QaApZaBuk6cKkx6qeFxeg1T+/idGgtWJI +Qg3i9fHzOIFUXwk51R3xh5IimvMJZ9Ii0wJAb7yrsx9tB3MUoSGZkTb8kholqZOl +fcEcOqcQYemuF1qdvoc6vHi4osnlt7L6JOkmLPCWcQu2GwNtZczZ65pruQJBAJ3p +vbtzUuF01TKbC18Cda7N5/zkZUl5ENCNXTRYS7lBuQhuqc8okChjufSJpJlTMUuC +Sis5OV5/3ROYTEC+ADsCQCwq6VQ1kXRrM+3tkMwi2rZi73dsFVuFx8crlBOmvhkD +U7Ar9bW13qhBeH9px8RCRDMWTGQcxY/C/TEQc/qvhkI= +-----END RSA PRIVATE KEY-----} + + IDEAL_CERTIFICATE = %{-----BEGIN CERTIFICATE----- +MIIEAzCCA3CgAwIBAgIQMIEnzk1UPrPDLOY9dc2cUjANBgkqhkiG9w0BAQUFADBf +MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXUlNBIERhdGEgU2VjdXJpdHksIEluYy4x +LjAsBgNVBAsTJVNlY3VyZSBTZXJ2ZXIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw +HhcNMDQwNjA4MDAwMDAwWhcNMDUwNjA4MjM1OTU5WjCBvDELMAkGA1UEBhMCTkwx +FjAUBgNVBAgTDU5vb3JkLUhvbGxhbmQxEjAQBgNVBAcUCUFtc3RlcmRhbTEbMBkG +A1UEChQSQUJOIEFNUk8gQmFuayBOLlYuMRYwFAYDVQQLFA1JTi9OUy9FLUlORlJB +MTMwMQYDVQQLFCpUZXJtcyBvZiB1c2UgYXQgd3d3LnZlcmlzaWduLmNvbS9ycGEg +KGMpMDAxFzAVBgNVBAMUDnd3dy5hYm5hbXJvLm5sMIGfMA0GCSqGSIb3DQEBAQUA +A4GNADCBiQKBgQD1hPZlFD01ZdQu0GVLkUQ7tOwtVw/jmZ1Axu8v+3bxrjKX9Qi1 +0w6EIadCXScDMmhCstExVptaTEQ5hG3DedV2IpMcwe93B1lfyviNYlmc/XIol1B7 +PM70mI9XUTYAoJpquEv8AaupRO+hgxQlz3FACHINJxEIMgdxa1iyoJfCKwIDAQAB +o4IBZDCCAWAwCQYDVR0TBAIwADALBgNVHQ8EBAMCBaAwPAYDVR0fBDUwMzAxoC+g +LYYraHR0cDovL2NybC52ZXJpc2lnbi5jb20vUlNBU2VjdXJlU2VydmVyLmNybDBE +BgNVHSAEPTA7MDkGC2CGSAGG+EUBBxcDMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8v +d3d3LnZlcmlzaWduLmNvbS9ycGEwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF +BwMCMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AudmVy +aXNpZ24uY29tMG0GCCsGAQUFBwEMBGEwX6FdoFswWTBXMFUWCWltYWdlL2dpZjAh +MB8wBwYFKw4DAhoEFI/l0xqGrI2Oa8PPgGrUSBgsexkuMCUWI2h0dHA6Ly9sb2dv +LnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMA0GCSqGSIb3DQEBBQUAA34AY7BYsNvj +i5fjnEHPlGOd2yxseCHU54HDPPCZOoP9a9kVWGX8tuj2b1oeiOsIbI1viIo+O4eQ +ilZjTJIlLOkXk6uE8vQGjZy0BUnjNPkXOQGkTyj4jDxZ2z+z9Vy8BwfothdcYbZK +48ZOp3u74DdEfQejNxBeqLODzrxQTV4= +-----END CERTIFICATE-----} + + DIRECTORY_RESPONSE_WITH_ONE_ISSUER = %{ + + 2001-12-17T09:30:47.0Z + + 0245 + + + 2004-11-10T10:15:12.145Z + + 1006 + ABN AMRO Bank + Short + + +} + + DIRECTORY_RESPONSE_WITH_MULTIPLE_ISSUERS = %{ + + 2001-12-17T09:30:47.0Z + + 0245 + + + 2004-11-10T10:15:12.145Z + + 1006 + ABN AMRO Bank + Short + + + 1003 + Postbank + Short + + + 1005 + Rabobank + Short + + + 1017 + Asr bank + Long + + + 1023 + Van Lanschot + Long + + +} + + ACQUIRER_TRANSACTION_RESPONSE = %{ + + 2001-12-17T09:30:47.0Z + + 1545 + + + https://ideal.example.com/long_service_url + + + 0001023456789112 + iDEAL-aankoop 21 + +} + + ACQUIRER_SUCCEEDED_STATUS_RESPONSE = %{ + + 2001-12-17T09:30:47.0Z + + 1234 + + + 0001023456789112 + Success + Onderheuvel + 0949298989 + DEN HAAG + + + db82/jpJRvKQKoiDvu33X0yoDAQpayJOaW2Y8zbR1qk1i3epvTXi+6g+QVBY93YzGv4w+Va+vL3uNmzyRjYsm2309d1CWFVsn5Mk24NLSvhYfwVHEpznyMqizALEVUNSoiSHRkZUDfXowBAyLT/tQVGbuUuBj+TKblY826nRa7U= + 1E15A00E3D7DF085768749D4ABBA3284794D8AE9 + +} + + ACQUIRER_SUCCEEDED_BUT_WRONG_SIGNATURE_STATUS_RESPONSE = %{ + + 2001-12-17T09:30:47.0Z + + 1234 + + + 0001023456789112 + Success + Onderheuvel + 0949298989 + DEN HAAG + + + WRONG + 1E15A00E3D7DF085768749D4ABBA3284794D8AE9 + +} + + ACQUIRER_FAILED_STATUS_RESPONSE = %{ + + 2001-12-17T09:30:47.0Z + + 1234 + + + 0001023456789112 + Failed + Onderheuvel + 0949298989 + DEN HAAG + + + db82/jpJRvKQKoiDvu33X0yoDAQpayJOaW2Y8zbR1qk1i3epvTXi+6g+QVBY93YzGv4w+Va+vL3uNmzyRjYsm2309d1CWFVsn5Mk24NLSvhYfwVHEpznyMqizALEVUNSoiSHRkZUDfXowBAyLT/tQVGbuUuBj+TKblY826nRa7U= + 1E15A00E3D7DF085768749D4ABBA3284794D8AE9 + +} + + ERROR_RESPONSE = %{ + + 2001-12-17T09:30:47.0Z + + SO1000 + Failure in system + System generating error: issuer + + + Betalen met iDEAL is nu niet mogelijk. + +} + + setup_ideal_gateway! +end \ No newline at end of file