From efb0890b89654d86f00b743b487284e505f74e02 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 11 Aug 2010 21:28:43 +0200 Subject: [PATCH] Working on adding Sermepa support, not yet complete --- .../billing/integrations/sermepa.rb | 157 ++++++++++++++++ .../billing/integrations/sermepa/helper.rb | 169 ++++++++++++++++++ .../integrations/sermepa/notification.rb | 140 +++++++++++++++ .../billing/integrations/sermepa/return.rb | 10 ++ .../helpers/sermepa_helper_test.rb | 79 ++++++++ .../sermepa_notification_test.rb | 46 +++++ test/unit/integrations/sermepa_module_test.rb | 39 ++++ 7 files changed, 640 insertions(+) create mode 100644 lib/active_merchant/billing/integrations/sermepa.rb create mode 100644 lib/active_merchant/billing/integrations/sermepa/helper.rb create mode 100644 lib/active_merchant/billing/integrations/sermepa/notification.rb create mode 100644 lib/active_merchant/billing/integrations/sermepa/return.rb create mode 100644 test/unit/integrations/helpers/sermepa_helper_test.rb create mode 100644 test/unit/integrations/notifications/sermepa_notification_test.rb create mode 100644 test/unit/integrations/sermepa_module_test.rb diff --git a/lib/active_merchant/billing/integrations/sermepa.rb b/lib/active_merchant/billing/integrations/sermepa.rb new file mode 100644 index 00000000000..73888534795 --- /dev/null +++ b/lib/active_merchant/billing/integrations/sermepa.rb @@ -0,0 +1,157 @@ +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + module Integrations #:nodoc: + # See the BbvaTpv::Helper class for more generic information on usage of + # this integrated payment method. + module Sermepa + + autoload :Helper, 'active_merchant/billing/integrations/sermepa/helper.rb' + autoload :Return, 'active_merchant/billing/integrations/sermepa/return.rb' + autoload :Notification, 'active_merchant/billing/integrations/sermepa/notification.rb' + + mattr_accessor :service_test_url + self.service_test_url = "https://sis-t.sermepa.es:25443/sis/realizarPago" + mattr_accessor :service_production_url + self.service_production_url = "https://sis.sermepa.es/sis/realizarPago" + + mattr_accessor :operations_test_url + self.operations_test_url = "https://sis-t.sermepa.es:25443/sis/operaciones" + mattr_accessor :operations_production_url + self.operations_production_url = "https://sis.sermepa.es/sis/operaciones" + + + def self.service_url + mode = ActiveMerchant::Billing::Base.integration_mode + case mode + when :production + self.service_production_url + when :test + self.service_test_url + else + raise StandardError, "Integration mode set to an invalid value: #{mode}" + end + end + + def self.operations_url + mode = ActiveMerchant::Billing::Base.integration_mode + case mode + when :production + self.operations_production_url + when :test + self.operations_test_url + else + raise StandardError, "Integration mode set to an invalid value: #{mode}" + end + + end + + def self.notification(post) + Notification.new(post) + end + + + def self.currency_code( name ) + row = supported_currencies.assoc(name) + row.nil? ? supported_currencies.first[1] : row[1] + end + + def self.currency_from_code( code ) + row = supported_currencies.rassoc(code) + row.nil? ? supported_currencies.first[0] : row[0] + end + + def self.language_code(name) + row = supported_languages.assoc(name.to_s.downcase.to_sym) + row.nil? ? supported_languages.first[1] : row[1] + end + + def self.language_from_code( code ) + row = supported_languages.rassoc(code) + row.nil? ? supported_languages.first[0] : row[0] + end + + def self.transaction_code(name) + row = supported_transactions.assoc(name.to_sym) + row.nil? ? supported_transactions.first[1] : row[1] + end + def self.transaction_from_code(code) + row = supported_transactions.rassoc(code.to_s) + row.nil? ? supported_languages.first[0] : row[0] + end + + def self.supported_currencies + [ ['EUR', '978'] ] + end + + def self.supported_languages + [ + [:es, '001'], + [:en, '002'], + [:ca, '003'], + [:fr, '004'], + [:de, '005'], + [:pt, '009'] + ] + end + + def self.supported_transactions + [ + [:authorization, '0'], + [:preauthorization, '1'], + [:confirmation, '2'], + [:automatic_return, '3'], + [:reference_payment, '4'], + [:recurring_transaction, '5'], + [:successive_transaction, '6'], + [:authentication, '7'], + [:confirm_authentication, '8'], + [:cancel_preauthorization, '9'], + [:deferred_authorization, 'O'], + [:confirm_deferred_authorization, 'P'], + [:cancel_deferred_authorization, 'Q'], + [:inicial_recurring_authorization, 'R'], + [:successive_recurring_authorization, 'S'] + ] + end + + def self.response_code_message(code) + case code.to_i + when 0..99 + nil + when 900 + "Transacción autorizada para devoluciones y confirmaciones" + when 101 + "Tarjeta caducada" + when 102 + "Tarjeta en excepción transitoria o bajo sospecha de fraude" + when 104 + "Operación no permitida para esa tarjeta o terminal" + when 116 + "Disponible insuficiente" + when 118 + "Tarjeta no registrada o Método de pago no disponible para su tarjeta" + when 129 + "Código de seguridad (CVV2/CVC2) incorrecto" + when 180 + "Tarjeta no válida o Tarjeta ajena al servicio o Error en la llamada al MPI sin controlar." + when 184 + "Error en la autenticación del titular" + when 190 + "Denegación sin especificar Motivo" + when 191 + "Fecha de caducidad errónea" + when 202 + "Tarjeta en excepción transitoria o bajo sospecha de fraude con retirada de tarjeta" + when 912,9912 + "Emisor no disponible" + when 913 + "Pedido repetido" + else + "Transacción denegada" + end + end + + end + end + end +end diff --git a/lib/active_merchant/billing/integrations/sermepa/helper.rb b/lib/active_merchant/billing/integrations/sermepa/helper.rb new file mode 100644 index 00000000000..4542a7cbcc8 --- /dev/null +++ b/lib/active_merchant/billing/integrations/sermepa/helper.rb @@ -0,0 +1,169 @@ +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + module Integrations #:nodoc: + module Sermepa + # Sermepa/Servired Spanish Virtual POS Gateway + # + # Support for the Spanish point of sale system provided by Sermepa, part of Servired, + # one of the main providers in Spain to Banks and Cajas. + # + # Requires the :terminal_id, :commercial_id, and :secret_key to be set in the credentials + # before the helper can be used. + # + class Helper < ActiveMerchant::Billing::Integrations::Helper + include PostsData + + class << self + # Credentials should be set as a hash containing the fields: + # :terminal_id, :commercial_id, :secret_key + attr_accessor :credentials + end + + mapping :account, 'Ds_Merchant_MerchantCode' + + mapping :currency, 'Ds_Merchant_Currency' + mapping :amount, 'Ds_Merchant_Amount' + + mapping :order, 'Ds_Merchant_Order' + mapping :description, 'Ds_Merchant_Product_Description' + mapping :client, 'Ds_Merchant_Titular' + + mapping :notify_url, 'Ds_Merchant_MerchantURL' + mapping :success_url, 'Ds_Merchant_UrlOK' + mapping :failure_url, 'Ds_Merchant_UrlKO' + + mapping :language, 'Ds_Merchant_ConsumerLanguage' + + mapping :transaction, 'Ds_Merchant_TransactionType' + + #### Special Request Specific Fields #### + mapping :signature, 'Ds_Merchant_MerchantSignature' + mapping :terminal, 'Ds_Merchant_Terminal' + ######## + + # ammount should always be provided in cents! + def initialize(order, account, options = {}) + self.credentials = options.delete(:credentials) if options[:credentials] + + # Replace account with commercial_id + super(order, credentials[:commercial_id], options) + + add_field mappings[:transaction], '0' # Default Transaction Type + add_field mappings[:terminal], credentials[:terminal_id] + end + + # Allow credentials to be overwritten if needed + def credentials + @credentials || self.class.credentials + end + def credentials=(creds) + @credentials = (self.class.credentials || {}).dup.merge(creds) + end + + def amount=(money) + cents = money.respond_to?(:cents) ? money.cents : money + if money.is_a?(String) || cents.to_i <= 0 + raise ArgumentError, 'money amount must be either a Money object or a positive integer in cents.' + end + add_field mappings[:amount], sprintf("%.2f", cents.to_f/100) + end + + def order=(order_id) + order_id = order_id.to_s + if order_id !~ /^[0-9]{4}/ && order_id.length <= 8 + order_id = ('0' * 4) + order_id + end + regexp = /^[0-9]{4}[0-9a-zA-Z]{0,8}$/ + raise "Invalid order number format! First 4 digits must be numbers" if order_id !~ regexp + add_field mappings[:order], order_id + end + + def currency=( value ) + add_field mappings[:currency], Sermepa.currency_code(value) + end + + def language=(lang) + add_field mappings[:language], Sermepa.language_code(lang) + end + + def transaction=(type) + add_field mappings[:transaction], (Sermepa.supported_transactions.assoc(type) || [])[1] + end + + def form_fields + add_field mappings[:signature], sign_request + @fields + end + + + # Send a manual request for the notification object. + # This is used to confirm a purchase if one was not sent by the gateway. + def request_notification + body = build_xml_confirmation_request + + headers = { } + headers['Content-Length'] = body.size.to_s + headers['User-Agent'] = "Active Merchant -- http://activemerchant.org" + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + response = ssl_post(Sermepa.operations_url, body, headers) + Notification.new response + end + + protected + + def build_xml_confirmation_request + self.transaction = :confirmation + xml = Builder::XmlMarkup.new :indent => 2 + xml.datosentrada do + xml.ds_version 0.1 + xml.ds_merchant_currency @fields['Ds_Merchant_Currency'] + xml.ds_merchant_merchanturl @fields['Ds_Merchant_MerchantURL'] + xml.ds_merchant_transactiontype @fields['Ds_Merchant_TransactionType'] + xml.ds_merchant_merchantdata @fields['Ds_Merchant_Product_Description'] + xml.ds_merchant_terminal credentials[:terminal_id] + xml.ds_merchant_merchantcode credentials[:commercial_id] + xml.ds_merchant_order @fields['Ds_Merchant_Order'] + xml.ds_merchant_merchantsignature sign_request + end + xml.target! + end + + + # Generate a signature authenticating the current request. + # Values included in the signature are determined by the the type of + # transaction. + def sign_request(strength = :normal) + str = (@fields['Ds_Merchant_Amount'].to_f * 100).to_i.to_s + + @fields['Ds_Merchant_Order'].to_s + + @fields['Ds_Merchant_MerchantCode'].to_s + + @fields['Ds_Merchant_Currency'].to_s + + case Sermepa.transaction_from_code(@fields['Ds_Merchant_TransactionType']) + when :recurring_transaction + str += @fields['Ds_Merchant_SumTotal'] + + # Add transaction type for the following requests performed only using XML + when :confirmation, :automatic_return, :successive_transaction, + :confirm_authentication, :cancel_preauthorization, :preauthorization, + :deferred_authorization, :confirm_deferred_authorization, :cancel_deferred_authorization, + :initial_recurring_authorization, :successive_recurring_authorization + str += @fields['Ds_Merchant_TransactionType'] + strength = :normal # Force the strength! + end + + if strength == :extended + str += @fields['Ds_Merchant_TransactionType'].to_s + + @fields['Ds_Merchant_MerchantURL'].to_s + end + + str += credentials[:secret_key] + + Digest::SHA1.hexdigest( str ) + end + + end + end + end + end +end diff --git a/lib/active_merchant/billing/integrations/sermepa/notification.rb b/lib/active_merchant/billing/integrations/sermepa/notification.rb new file mode 100644 index 00000000000..e727afa22e0 --- /dev/null +++ b/lib/active_merchant/billing/integrations/sermepa/notification.rb @@ -0,0 +1,140 @@ +require 'nokogiri' + +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + module Integrations #:nodoc: + module Sermepa + class Notification < ActiveMerchant::Billing::Integrations::Notification + include PostsData + + def complete? + status == 'Compelted' + end + + def transaction_id + params['ds_order'] + end + + # When was this payment received by the client. + def received_at + Time.now # Not provided! + end + + # the money amount we received in X.2 decimal. + def gross + params['ds_amount'].sub(/,/, '.').to_f + end + + # Was this a test transaction? + def test? + false + end + + def currency + Sermepa.currency_from_code( params['ds_currency'] ) + end + + # Status of transaction. List of possible values: + # Completed + # Failed + # Pending + def status + case error_code.to_i + when 0..99 + 'Completed' + when 900 + 'Pending' + else + 'Failed' + end + end + + def error_code + params['ds_response'] + end + + def error_message + msg = Sermepa.response_code_message(error_code) + error_code.to_s + ' - ' + (msg.nil? ? 'Operación Aceptada' : msg) + end + + def secure_payment? + params['ds_securepayment'] == '1' + end + + def xml? + !params['code'].nil? + end + + # Acknowledge and confirm the transaction. + # + # If the transaction was standard 'authorization', calling this method will simply validate + # the signature received to ensure it has not been falsified. + # + # If the original transaction type was 'deferred_authorization', calling acknowledge + # will send a request to confirm the payment and finalize the purchase. + # + # Optionally, the secret key can be provided + # + # Example: + # + # def notify + # notify = Sermepa::Notification.new(request.raw_post) + # + # if notify.acknowledge + # ... process order ... if notify.complete? + # else + # ... log possible hacking attempt ... + # end + # + def acknowledge(secret_key = nil) + str = + gross_cents.to_s + + params['ds_order'] + + params['ds_merchantcode'] + + params['ds_currency'] + + params['ds_response'] + if xml? + str += params['ds_transactiontype'] + params['ds_securepayment'] + end + + str += (secret_key || Sermepa::Helper.secret_key) + sig = Digest::SHA1.hexdigest(str) + sig.upcase == params['ds_signature'].upcase + end + + private + + # Take the posted data and try to extract the parameters. + # + # Posted data can either be an XML string or CGI data in which case + # a hash is expected. + # + def parse(post) + @raw = post.to_s + if @raw =~ //i + # XML source + self.params = xml_response_to_hash(@raw) + else + for line in @raw.split('&') + key, value = *line.scan( %r{^([A-Za-z0-9_.]+)\=(.*)$} ).flatten + params[key.downcase] = CGI.unescape(value) + end + end + end + + def xml_response_to_hash(xml) + result = { } + doc = Nokogiri::XML(xml) + doc.css('retornoxml operacion').children().each do |child| + result[child.name.downcase] = child.inner_text + end + result['code'] = doc.css('retornoxml codigo').inner_text + result + end + + end + end + end + end +end diff --git a/lib/active_merchant/billing/integrations/sermepa/return.rb b/lib/active_merchant/billing/integrations/sermepa/return.rb new file mode 100644 index 00000000000..25753bf60b5 --- /dev/null +++ b/lib/active_merchant/billing/integrations/sermepa/return.rb @@ -0,0 +1,10 @@ +module ActiveMerchant #:nodoc: + module Billing #:nodoc: + module Integrations #:nodoc: + module BbvaTpv + class Return < ActiveMerchant::Billing::Integrations::Return + end + end + end + end +end diff --git a/test/unit/integrations/helpers/sermepa_helper_test.rb b/test/unit/integrations/helpers/sermepa_helper_test.rb new file mode 100644 index 00000000000..f0d8c6293ea --- /dev/null +++ b/test/unit/integrations/helpers/sermepa_helper_test.rb @@ -0,0 +1,79 @@ +require File.dirname(__FILE__) + '/../../../test_helper' + +class SermepaHelperTest < Test::Unit::TestCase + include ActiveMerchant::Billing::Integrations + + def setup + Sermepa::Helper.credentials = { + :terminal_id => '1', + :commercial_id => '201920191', + :secret_key => 'h2u282kMks01923kmqpo' + } + @helper = Sermepa::Helper.new(29292929, 'cody@example.com', :amount => 1235, :currency => 'EUR') + @helper.description = "Store Purchase" + end + + def test_credentials_accessible + assert_instance_of Hash, @helper.credentials + end + + def test_credentials_overwritable + @helper = Sermepa::Helper.new(29292929, 'cody@example.com', :amount => 1235, :currency => 'EUR', + :credentials => {:terminal_id => 12}) + assert_field 'Ds_Merchant_Terminal', '12' + end + + def test_basic_helper_fields + assert_field 'Ds_Merchant_MerchantCode', '201920191' + assert_field 'Ds_Merchant_Amount', '12.35' + assert_field 'Ds_Merchant_Order', '29292929' + assert_field 'Ds_Merchant_Product_Description', 'Store Purchase' + assert_field 'Ds_Merchant_Currency', '978' + assert_field 'Ds_Merchant_TransactionType', '0' + end + + def test_unknown_mapping + assert_nothing_raised do + @helper.company_address :address => '500 Dwemthy Fox Road' + end + end + + def test_padding_on_order_id + @helper.order = 101 + assert_field 'Ds_Merchant_Order', "0000101" + end + + def test_no_padding_on_valid_order_id + @helper.order = 1010 + assert_field 'Ds_Merchant_Order', "1010" + end + + def test_error_raised_on_invalid_order_id + assert_raise RuntimeError do + @helper.order = "A0000000ABC" + end + end + + def test_basic_signing_request + assert sig = @helper.send(:sign_request) + assert_equal "c8392b7874e2994c74fa8bea3e2dff38f3913c46", sig + end + + def test_build_xml_confirmation_request + # This also tests signing the request for differnet transactions + assert_equal @helper.send(:build_xml_confirmation_request), < + 0.1 + 978 + + 2 + Store Purchase + 1 + 201920191 + 29292929 + dec4048a3aefefd22798347ee1c1f19011fd47f6 + +EOF + end + +end diff --git a/test/unit/integrations/notifications/sermepa_notification_test.rb b/test/unit/integrations/notifications/sermepa_notification_test.rb new file mode 100644 index 00000000000..38cad6a1ea4 --- /dev/null +++ b/test/unit/integrations/notifications/sermepa_notification_test.rb @@ -0,0 +1,46 @@ +require File.dirname(__FILE__) + '/../../../test_helper' + +class SermepaNotificationTest < Test::Unit::TestCase + include ActiveMerchant::Billing::Integrations + + def setup + Sermepa::Helper.credentials = { + :terminal_id => '1', + :commercial_id => '201920191', + :secret_key => 'h2u282kMks01923kmqpo' + } + @sermepa = Sermepa::Notification.new(http_raw_data) + end + + def test_accessors + assert @sermepa.complete? + assert_equal "Completed", @sermepa.status + assert_equal "000000000004", @sermepa.transaction_id + assert_equal "114.00", @sermepa.gross + assert_equal "EUR", @sermepa.currency + assert_equal Time.parse("2009-04-02 12:45:41"), @sermepa.received_at + end + + def test_compositions + assert_equal Money.new(11400, 'EUR'), @sermepa.amount + end + + # Replace with real successful acknowledgement code + def test_acknowledgement + # assert @sermepa.acknowledge + end + + def test_send_acknowledgement + end + + def test_respond_to_acknowledge + assert @sermepa.respond_to?(:acknowledge) + end + + private + def http_raw_data + { + + } + end +end diff --git a/test/unit/integrations/sermepa_module_test.rb b/test/unit/integrations/sermepa_module_test.rb new file mode 100644 index 00000000000..9e579b62752 --- /dev/null +++ b/test/unit/integrations/sermepa_module_test.rb @@ -0,0 +1,39 @@ +require File.dirname(__FILE__) + '/../../test_helper' + +class SermepaModuleTest < Test::Unit::TestCase + include ActiveMerchant::Billing::Integrations + + def test_notification_method + assert_instance_of Sermepa::Notification, Sermepa.notification('name=cody') + end + + def test_currency_code + assert_equal '978', Sermepa.currency_code('EUR') + end + def test_currency_from_code + assert_equal 'EUR', Sermepa.currency_from_code('978') + end + + def test_language_code + assert_equal Sermepa.language_code('es'), '001' + assert_equal Sermepa.language_code('CA'), '003' + assert_equal Sermepa.language_code(:pt), '009' + end + def test_language_from_code + assert_equal :ca, Sermepa.language_from_code('003') + end + + def test_transaction_code + assert_equal '2', Sermepa.transaction_code(:confirmation) + end + def test_transaction_from_code + assert_equal :confirmation, Sermepa.transaction_from_code(2) + end + + def test_response_code_message + assert_equal nil, Sermepa.response_code_message(23) + assert_equal nil, Sermepa.response_code_message('23') + assert_equal "Tarjeta caducada", Sermepa.response_code_message(101) + end + +end