active_merchant_ideal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ require 'openssl'
2
+ require 'net/https'
3
+ require 'base64'
4
+ require 'digest/sha1'
5
+
6
+ module ActiveMerchant #:nodoc:
7
+ module Billing #:nodoc:
8
+ # == iDEAL
9
+ #
10
+ # iDEAL is a set of standards developed to facilitate online payments
11
+ # through the online banking applications that most Dutch banks provide.
12
+ #
13
+ # If a consumer already has online banking with ABN AMRO, Fortis,
14
+ # ING/Postbank, Rabobank, or SNS Bank, they can make payments using iDEAL in
15
+ # a way that they are already familiar with.
16
+ #
17
+ # See http://ideal.nl and http://idealdesk.com for more information.
18
+ #
19
+ # ==== Merchant account
20
+ #
21
+ # In order to use iDEAL you will need to get an iDEAL merchant account from
22
+ # your bank. Every bank offers ‘complete payment’ services, which can
23
+ # obfuscate the right choice. The payment product that you will want to
24
+ # get, in order to use this gateway class, is a bare bones iDEAL account.
25
+ #
26
+ # * ING/Postbank: iDEAL Advanced
27
+ # * ABN AMRO: iDEAL Zelfbouw
28
+ # * Fortis: ? (Unknown)
29
+ # * Rabobank: Rabo iDEAL Professional. (Unverified)
30
+ # * SNS Bank: Not yet available. (http://www.snsbank.nl/zakelijk/betalingsverkeer/kan-ik-ideal-gebruiken-voor-mijn-webwinkel.html)
31
+ #
32
+ # At least the ING bank requires you to perform 7 remote tests which have
33
+ # to pass before you will get access to the live environment. These tests
34
+ # have been implemented in the remote tests. Running these should be enough:
35
+ #
36
+ # test/remote/remote_ideal_test.rb
37
+ #
38
+ # If you implement tests for other banks, if they require such acceptance
39
+ # tests, please do submit a patch or contact me directly: frank@dovadi.com.
40
+ #
41
+ # ==== Private keys, certificates and all that jazz
42
+ #
43
+ # Messages to, and from, the acquirer, are all signed in order to prove
44
+ # their authenticity. This means that you will have to have a certificate
45
+ # to sign your messages going to the acquirer _and_ you will need to have
46
+ # the certificate of the acquirer to verify its signed messages.
47
+ #
48
+ # The latter can be downloaded from your acquirer after registration.
49
+ # The former, however, can be a certificate signed by a CA authority or a
50
+ # self-signed certificate.
51
+ #
52
+ # To create a self-signed certificate follow these steps:
53
+ #
54
+ # $ /usr/bin/openssl genrsa -des3 -out private_key.pem -passout pass:the_passphrase 1024
55
+ # $ /usr/bin/openssl req -x509 -new -key private_key.pem -passin pass:the_passphrase -days 3650 -out private_certificate.cer
56
+ #
57
+ # Substitute <tt>the_passphrase</tt> with your own passphrase.
58
+ #
59
+ # For more information see:
60
+ # * http://en.wikipedia.org/wiki/Certificate_authority
61
+ # * http://en.wikipedia.org/wiki/Self-signed_certificate
62
+ #
63
+ # === Example (Rails)
64
+ #
65
+ # ==== First configure the gateway
66
+ #
67
+ # Put the following code in, for instance, an initializer:
68
+ #
69
+ # IdealGateway.live_url = 'https://ideal.secure-ing.com:443/ideal/iDeal'
70
+ #
71
+ # IdealGateway.merchant_id = '00123456789'
72
+ #
73
+ # # CERTIFICATE_ROOT points to a directory where the key and certificates are located.
74
+ # IdealGateway.passphrase = 'the_private_key_passphrase'
75
+ # IdealGateway.private_key_file = File.join(CERTIFICATE_ROOT, 'private_key.pem')
76
+ # IdealGateway.private_certificate_file = File.join(CERTIFICATE_ROOT, 'private_certificate.cer')
77
+ # IdealGateway.ideal_certificate_file = File.join(CERTIFICATE_ROOT, 'ideal.cer')
78
+ #
79
+ # ==== View
80
+ #
81
+ # Give the consumer a list of available issuer options:
82
+ #
83
+ # gateway = ActiveMerchant::Billing::IdealGateway.new
84
+ # issuers = gateway.issuers.list
85
+ # sorted_issuers = issuers.sort_by { |issuer| issuer[:name] }
86
+ # select('purchase', 'issuer_id', issuers.map { |issuer| [issuer[:name], issuer[:id]] })
87
+ #
88
+ # Could become:
89
+ #
90
+ # <select name="purchase[issuer_id]">
91
+ # <option value="1006" selected="selected">ABN AMRO Bank</option>
92
+ # <option value="1017">Asr bank</option>
93
+ # <option value="1003">Postbank</option>
94
+ # <option value="1005">Rabobank</option>
95
+ # <option value="1023">Van Lanschot</option>
96
+ # </select>
97
+ #
98
+ # ==== Controller
99
+ #
100
+ # First you'll need to setup a transaction and redirect the consumer there
101
+ # so she can make the payment:
102
+ #
103
+ # class PurchasesController < ActionController::Base
104
+ # def create
105
+ # purchase = @user.purchases.build(:price => 1000) # €10.00 in cents.
106
+ # purchase.save(false) # We want an id for the URL.
107
+ #
108
+ # purchase_options = {
109
+ # :issuer_id => params[:purchase][:issuer_id],
110
+ # :order_id => purchase.id,
111
+ # :return_url => purchase_url(purchase),
112
+ # :description => 'A Dutch windmill'
113
+ # }
114
+ #
115
+ # # Save the purchase instance so that the consumer can return to its resource url to finish the transaction.
116
+ # purchase.update_attributes!(purchase_options)
117
+ #
118
+ # gateway = ActiveMerchant::Billing::IdealGateway.new
119
+ # transaction_response = gateway.setup_purchase(purchase.price, purchase_options)
120
+ # if transaction_response.success?
121
+ #
122
+ # # Store the transaction_id that the acquirer has created to identify the transaction.
123
+ # purchase.update_attributes!(:transaction_id => transaction_response.transaction_id)
124
+ #
125
+ # # Redirect the consumer to the issuer’s payment page.
126
+ # redirect_to transaction_response.service_url
127
+ # end
128
+ # end
129
+ # end
130
+ #
131
+ # After the consumer is done with the payment she will be redirected to the
132
+ # <tt>:return_url</tt>. It's now _your_ responsibility as merchant to check
133
+ # if the payment has been made:
134
+ #
135
+ # class PurchasesController < ActionController::Base
136
+ # def show
137
+ # gateway = ActiveMerchant::Billing::IdealGateway.new
138
+ # transaction_status = gateway.capture(@purchase.transaction_id)
139
+ #
140
+ # if transaction_status.success?
141
+ # @purchase.update_attributes!(:paid => true)
142
+ # flash[:notice] = "Congratulations, you are now the proud owner of a Dutch windmill!"
143
+ # end
144
+ # end
145
+ # end
146
+ #
147
+ # === Response classes
148
+ #
149
+ # * IdealResponse
150
+ # * IdealTransactionResponse
151
+ # * IdealStatusResponse
152
+ # * IdealDirectoryResponse
153
+ #
154
+ # See the IdealResponse base class for more information on errors.
155
+ class IdealGateway < Gateway
156
+ AUTHENTICATION_TYPE = 'SHA1_RSA'
157
+ LANGUAGE = 'nl'
158
+ CURRENCY = 'EUR'
159
+ API_VERSION = '1.1.0'
160
+ XML_NAMESPACE = 'http://www.idealdesk.com/Message'
161
+
162
+ # Assigns the global iDEAL merchant id. Make sure to use a string with
163
+ # leading zeroes if needed.
164
+ cattr_accessor :merchant_id
165
+
166
+ # Assigns the passphrase that should be used for the merchant private_key.
167
+ cattr_accessor :passphrase
168
+
169
+ # Loads the global merchant private_key from disk.
170
+ def self.private_key_file=(pkey_file)
171
+ self.private_key = File.read(pkey_file)
172
+ end
173
+
174
+ # Instantiates and assings a OpenSSL::PKey::RSA instance with the
175
+ # provided private key data.
176
+ def self.private_key=(pkey_data)
177
+ @private_key = OpenSSL::PKey::RSA.new(pkey_data, passphrase)
178
+ end
179
+
180
+ # Returns the global merchant private_certificate.
181
+ def self.private_key
182
+ @private_key
183
+ end
184
+
185
+ # Loads the global merchant private_certificate from disk.
186
+ def self.private_certificate_file=(certificate_file)
187
+ self.private_certificate = File.read(certificate_file)
188
+ end
189
+
190
+ # Instantiates and assings a OpenSSL::X509::Certificate instance with the
191
+ # provided private certificate data.
192
+ def self.private_certificate=(certificate_data)
193
+ @private_certificate = OpenSSL::X509::Certificate.new(certificate_data)
194
+ end
195
+
196
+ # Returns the global merchant private_certificate.
197
+ def self.private_certificate
198
+ @private_certificate
199
+ end
200
+
201
+ # Loads the global merchant ideal_certificate from disk.
202
+ def self.ideal_certificate_file=(certificate_file)
203
+ self.ideal_certificate = File.read(certificate_file)
204
+ end
205
+
206
+ # Instantiates and assings a OpenSSL::X509::Certificate instance with the
207
+ # provided iDEAL certificate data.
208
+ def self.ideal_certificate=(certificate_data)
209
+ @ideal_certificate = OpenSSL::X509::Certificate.new(certificate_data)
210
+ end
211
+
212
+ # Returns the global merchant ideal_certificate.
213
+ def self.ideal_certificate
214
+ @ideal_certificate
215
+ end
216
+
217
+ # Assign the test and production urls for your iDeal acquirer.
218
+ #
219
+ # For instance, for ING:
220
+ #
221
+ # ActiveMerchant::Billing::IdealGateway.test_url = "https://idealtest.secure-ing.com:443/ideal/iDeal"
222
+ # ActiveMerchant::Billing::IdealGateway.live_url = "https://ideal.secure-ing.com:443/ideal/iDeal"
223
+ cattr_accessor :test_url, :live_url
224
+
225
+ # Returns the merchant `subID' being used for this IdealGateway instance.
226
+ # Defaults to 0.
227
+ attr_reader :sub_id
228
+
229
+ # Initializes a new IdealGateway instance.
230
+ #
231
+ # You can optionally specify <tt>:sub_id</tt>. Defaults to 0.
232
+ def initialize(options = {})
233
+ @sub_id = options[:sub_id] || 0
234
+ super
235
+ end
236
+
237
+ # Returns the url of the acquirer matching the current environment.
238
+ #
239
+ # When #test? returns +true+ the IdealGateway.test_url is used, otherwise
240
+ # the IdealGateway.live_url is used.
241
+ def acquirer_url
242
+ test? ? self.class.test_url : self.class.live_url
243
+ end
244
+
245
+ # Sends a directory request to the acquirer and returns an
246
+ # IdealDirectoryResponse. Use IdealDirectoryResponse#list to receive the
247
+ # actuall array of available issuers.
248
+ #
249
+ # gateway.issuers.list # => [{ :id => '1006', :name => 'ABN AMRO Bank' }, …]
250
+ def issuers
251
+ post_data build_directory_request_body, IdealDirectoryResponse
252
+ end
253
+
254
+ # Starts a purchase by sending an acquirer transaction request for the
255
+ # specified +money+ amount in EURO cents.
256
+ #
257
+ # On success returns an IdealTransactionResponse with the #transaction_id
258
+ # which is needed for the capture step. (See capture for an example.)
259
+ #
260
+ # The iDEAL specification states that it is _not_ allowed to use another
261
+ # window or frame when redirecting the consumer to the issuer. So the
262
+ # entire merchant’s page has to be replaced by the selected issuer’s page.
263
+ #
264
+ # === Options
265
+ #
266
+ # Note that all options that have a character limit are _also_ checked
267
+ # for diacritical characters. If it does contain diacritical characters,
268
+ # or exceeds the character limit, an ArgumentError is raised.
269
+ #
270
+ # ==== Required
271
+ #
272
+ # * <tt>:issuer_id</tt> - The <tt>:id</tt> of an issuer available at the acquirer to which the transaction should be made.
273
+ # * <tt>:order_id</tt> - The order number. Limited to 12 characters.
274
+ # * <tt>:description</tt> - A description of the transaction. Limited to 32 characters.
275
+ # * <tt>:return_url</tt> - A URL on the merchant’s system to which the consumer is redirected _after_ payment. The acquirer will add the following GET variables:
276
+ # * <tt>trxid</tt> - The <tt>:order_id</tt>.
277
+ # * <tt>ec</tt> - The <tt>:entrance_code</tt> _if_ it was specified.
278
+ #
279
+ # ==== Optional
280
+ #
281
+ # * <tt>:entrance_code</tt> - This code is an abitrary token which can be used to identify the transaction besides the <tt>:order_id</tt>. Limited to 40 characters.
282
+ # * <tt>:expiration_period</tt> - 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 <tt>:expiration_period</tt> of `P3DT6H10M':
283
+ # * P: relative time designation.
284
+ # * 3 days.
285
+ # * T: separator.
286
+ # * 6 hours.
287
+ # * 10 minutes.
288
+ #
289
+ # === Example
290
+ #
291
+ # transaction_response = gateway.setup_purchase(4321, valid_options)
292
+ # if transaction_response.success?
293
+ # @purchase.update_attributes!(:transaction_id => transaction_response.transaction_id)
294
+ # redirect_to transaction_response.service_url
295
+ # end
296
+ #
297
+ # See the IdealGateway class description for a more elaborate example.
298
+ def setup_purchase(money, options)
299
+ post_data build_transaction_request_body(money, options), IdealTransactionResponse
300
+ end
301
+
302
+ # Sends a acquirer status request for the specified +transaction_id+ and
303
+ # returns an IdealStatusResponse.
304
+ #
305
+ # It is _your_ responsibility as the merchant to check if the payment has
306
+ # been made until you receive a response with a finished status like:
307
+ # `Success', `Cancelled', `Expired', everything else equals `Open'.
308
+ #
309
+ # === Example
310
+ #
311
+ # capture_response = gateway.capture(@purchase.transaction_id)
312
+ # if capture_response.success?
313
+ # @purchase.update_attributes!(:paid => true)
314
+ # flash[:notice] = "Congratulations, you are now the proud owner of a Dutch windmill!"
315
+ # end
316
+ #
317
+ # See the IdealGateway class description for a more elaborate example.
318
+ def capture(transaction_id)
319
+ post_data build_status_request_body(:transaction_id => transaction_id), IdealStatusResponse
320
+ end
321
+
322
+ private
323
+
324
+ def post_data(data, response_klass)
325
+ response_klass.new(ssl_post(acquirer_url, data), :test => test?)
326
+ end
327
+
328
+ # This is the list of charaters that are not supported by iDEAL according
329
+ # to the PHP source provided by ING plus the same in capitals.
330
+ DIACRITICAL_CHARACTERS = /[ÀÁÂÃÄÅÇŒÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝàáâãäåçæèéêëìíîïñòóôõöøùúûüý]/ #:nodoc:
331
+
332
+ # Raises an ArgumentError if the +string+ exceeds the +max_length+ amount
333
+ # of characters or contains any diacritical characters.
334
+ def ensure_validity(key, string, max_length)
335
+ raise ArgumentError, "The value for `#{key}' exceeds the limit of #{max_length} characters." if string.length > max_length
336
+ raise ArgumentError, "The value for `#{key}' contains diacritical characters `#{string}'." if string =~ DIACRITICAL_CHARACTERS
337
+ end
338
+
339
+ # Returns the +token+ as specified in section 2.8.4 of the iDeal specs.
340
+ #
341
+ # This is the params['AcquirerStatusRes']['Signature']['fingerprint'] in
342
+ # a IdealStatusResponse instance.
343
+ def token
344
+ Digest::SHA1.hexdigest(self.class.private_certificate.to_der).upcase
345
+ end
346
+
347
+ # Creates a +tokenCode+ from the specified +message+.
348
+ def token_code(message)
349
+ signature = self.class.private_key.sign(OpenSSL::Digest::SHA1.new, message.gsub(/\s/m, ''))
350
+ Base64.encode64(signature).gsub(/\s/m, '')
351
+ end
352
+
353
+ # Returns a string containing the current UTC time, formatted as per the
354
+ # iDeal specifications, except we don't use miliseconds.
355
+ def created_at_timestamp
356
+ Time.now.gmtime.strftime("%Y-%m-%dT%H:%M:%S.000Z")
357
+ end
358
+
359
+ # iDeal doesn't really seem to care about nice looking keys in their XML.
360
+ # Probably some Java XML class, hence the method name.
361
+ def javaize_key(key)
362
+ key = key.to_s
363
+ case key
364
+ when 'acquirer_transaction_request'
365
+ 'AcquirerTrxReq'
366
+ when 'acquirer_status_request'
367
+ 'AcquirerStatusReq'
368
+ when 'directory_request'
369
+ 'DirectoryReq'
370
+ when 'issuer', 'merchant', 'transaction'
371
+ key.capitalize
372
+ when 'created_at'
373
+ 'createDateTimeStamp'
374
+ when 'merchant_return_url'
375
+ 'merchantReturnURL'
376
+ when 'token_code', 'expiration_period', 'entrance_code'
377
+ key[0,1] + key.camelize[1..-1]
378
+ when /^(\w+)_id$/
379
+ "#{$1}ID"
380
+ else
381
+ key
382
+ end
383
+ end
384
+
385
+ # Creates xml with a given hash of tag-value pairs according to the iDeal
386
+ # requirements.
387
+ def xml_for(name, tags_and_values)
388
+ xml = Builder::XmlMarkup.new
389
+ xml.instruct!
390
+ xml.tag!(javaize_key(name), 'xmlns' => XML_NAMESPACE, 'version' => API_VERSION) { xml_from_array(xml, tags_and_values) }
391
+ xml.target!
392
+ end
393
+
394
+ # Recursively creates xml for a given hash of tag-value pair. Uses
395
+ # javaize_key on the tags to create the tags needed by iDeal.
396
+ def xml_from_array(builder, tags_and_values)
397
+ tags_and_values.each do |tag, value|
398
+ tag = javaize_key(tag)
399
+ if value.is_a?(Array)
400
+ builder.tag!(tag) { xml_from_array(builder, value) }
401
+ else
402
+ builder.tag!(tag, value)
403
+ end
404
+ end
405
+ end
406
+
407
+ def build_status_request_body(options)
408
+ requires!(options, :transaction_id)
409
+
410
+ timestamp = created_at_timestamp
411
+ message = "#{timestamp}#{self.class.merchant_id}#{@sub_id}#{options[:transaction_id]}"
412
+
413
+ xml_for(:acquirer_status_request, [
414
+ [:created_at, timestamp],
415
+ [:merchant, [
416
+ [:merchant_id, self.class.merchant_id],
417
+ [:sub_id, @sub_id],
418
+ [:authentication, AUTHENTICATION_TYPE],
419
+ [:token, token],
420
+ [:token_code, token_code(message)]
421
+ ]],
422
+
423
+ [:transaction, [
424
+ [:transaction_id, options[:transaction_id]]
425
+ ]]
426
+ ])
427
+ end
428
+
429
+ def build_directory_request_body
430
+ timestamp = created_at_timestamp
431
+ message = "#{timestamp}#{self.class.merchant_id}#{@sub_id}"
432
+
433
+ xml_for(:directory_request, [
434
+ [:created_at, timestamp],
435
+ [:merchant, [
436
+ [:merchant_id, self.class.merchant_id],
437
+ [:sub_id, @sub_id],
438
+ [:authentication, AUTHENTICATION_TYPE],
439
+ [:token, token],
440
+ [:token_code, token_code(message)]
441
+ ]]
442
+ ])
443
+ end
444
+
445
+ def build_transaction_request_body(money, options)
446
+ requires!(options, :issuer_id, :expiration_period, :return_url, :order_id, :description, :entrance_code)
447
+
448
+ ensure_validity(:money, money.to_s, 12)
449
+ ensure_validity(:order_id, options[:order_id], 12)
450
+ ensure_validity(:description, options[:description], 32)
451
+ ensure_validity(:entrance_code, options[:entrance_code], 40)
452
+
453
+ timestamp = created_at_timestamp
454
+ message = timestamp +
455
+ options[:issuer_id] +
456
+ self.class.merchant_id +
457
+ @sub_id.to_s +
458
+ options[:return_url] +
459
+ options[:order_id] +
460
+ money.to_s +
461
+ CURRENCY +
462
+ LANGUAGE +
463
+ options[:description] +
464
+ options[:entrance_code]
465
+
466
+ xml_for(:acquirer_transaction_request, [
467
+ [:created_at, timestamp],
468
+ [:issuer, [[:issuer_id, options[:issuer_id]]]],
469
+
470
+ [:merchant, [
471
+ [:merchant_id, self.class.merchant_id],
472
+ [:sub_id, @sub_id],
473
+ [:authentication, AUTHENTICATION_TYPE],
474
+ [:token, token],
475
+ [:token_code, token_code(message)],
476
+ [:merchant_return_url, options[:return_url]]
477
+ ]],
478
+
479
+ [:transaction, [
480
+ [:purchase_id, options[:order_id]],
481
+ [:amount, money],
482
+ [:currency, CURRENCY],
483
+ [:expiration_period, options[:expiration_period]],
484
+ [:language, LANGUAGE],
485
+ [:description, options[:description]],
486
+ [:entrance_code, options[:entrance_code]]
487
+ ]]
488
+ ])
489
+ end
490
+
491
+ end
492
+ end
493
+ end