active_merchant_ideal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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