# encoding: utf-8
require 'openssl'
require 'net/https'
require 'base64'
module Ideal
# 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 Gateway
LANGUAGE = 'nl'
CURRENCY = 'EUR'
API_VERSION = '3.3.1'
XML_NAMESPACE = 'http://www.idealdesk.com/ideal/messages/mer-acq/3.3.1'
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
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
x = build_directory_request
a= post_data request_url, x, DirectoryResponse
log('REQ',x)
log('RES',a.response)
a
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)
req = build_transaction_request(money, options)
log('purchase', req)
resp = post_data request_url, req, TransactionResponse
#raise SecurityError, "The message could not be verified" if !resp.verified?
resp
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)
a = build_status_request(:transaction_id => transaction_id)
log('REQ', a)
b = post_data request_url, a, StatusResponse
log('RES', b)
b
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
#signs the xml
def sign!(xml)
digest_val = digest_value(xml.doc.children[0])
xml.Signature(xmlns: 'http://www.w3.org/2000/09/xmldsig#') do |xml|
xml.SignedInfo do |xml|
xml.CanonicalizationMethod(Algorithm: 'http://www.w3.org/2001/10/xml-exc-c14n#')
xml.SignatureMethod(Algorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256')
xml.Reference(URI: '') do |xml|
xml.Transforms do |xml|
xml.Transform(Algorithm: 'http://www.w3.org/2000/09/xmldsig#enveloped-signature')
end
xml.DigestMethod(Algorithm: 'http://www.w3.org/2001/04/xmlenc#sha256')
xml.DigestValue digest_val
end
end
xml.SignatureValue signature_value(xml.doc.xpath("//Signature:SignedInfo", 'Signature' => 'http://www.w3.org/2000/09/xmldsig#')[0])
xml.KeyInfo do |xml|
xml.KeyName fingerprint
end
end
end
# Creates a +signatureValue+ from the xml+.
def signature_value(sig_val)
canonical = sig_val.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
signature = Ideal::Gateway.private_key.sign(OpenSSL::Digest::SHA256.new, canonical)
Base64.encode64(signature)
end
# Creates a +digestValue+ from the xml+.
def digest_value(xml)
canonical = xml.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
digest = OpenSSL::Digest::SHA256.new.digest canonical
Base64.encode64(digest)
end
# Creates a keyName value for the XML signature
def fingerprint
Digest::SHA1.hexdigest(Ideal::Gateway.private_certificate.to_der)
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 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(options)
requires!(options, :transaction_id)
timestamp = created_at_timestamp
Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
xml.AcquirerStatusReq(xmlns: XML_NAMESPACE, version: API_VERSION) do |xml|
xml.createDateTimestamp created_at_timestamp
xml.Merchant do |xml|
xml.merchantID self.class.merchant_id
xml.subID @sub_id
end
xml.Transaction do |xml|
xml.transactionID options[:transaction_id]
end
sign!(xml)
end
end.to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
end
def build_directory_request
timestamp = created_at_timestamp
xml = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
xml.DirectoryReq(xmlns: XML_NAMESPACE, version: API_VERSION) do |xml|
xml.createDateTimestamp created_at_timestamp
xml.Merchant do |xml|
xml.merchantID self.class.merchant_id
xml.subID @sub_id
end
sign!(xml)
end
end.to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
end
def build_transaction_request(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
Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
xml.AcquirerTrxReq(xmlns: XML_NAMESPACE, version: API_VERSION) do |xml|
xml.createDateTimestamp created_at_timestamp
xml.Issuer do |xml|
xml.issuerID options[:issuer_id]
end
xml.Merchant do |xml|
xml.merchantID self.class.merchant_id
xml.subID 0
xml.merchantReturnURL options[:return_url]
end
xml.Transaction do |xml|
xml.purchaseID options[:order_id]
xml.amount money
xml.currency CURRENCY
xml.expirationPeriod options[:expiration_period]
xml.language LANGUAGE
xml.description options[:description]
xml.entranceCode options[:entrance_code]
end
sign!(xml)
end
end.to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML)
end
def log(thing, contents)
$stderr.write("\n#{thing}:\n\n#{contents}\n") if $DEBUG
end
end
end