Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add Gateway for Moneris US

Fix unit tests for Moneris US XML

Moneris US cannot void a preauthorization
Bring this in line with the way Moneris Canada does things.
  • Loading branch information...
commit 2faffc1969348659b61d274ec154588594de74b1 1 parent dd91818
@eddanger eddanger authored
View
1  README.md
@@ -111,6 +111,7 @@ The [ActiveMerchant Wiki](http://github.com/Shopify/active_merchant/wikis) conta
* [MerchantWare](http://merchantwarehouse.com/merchantware) - US
* [Modern Payments](http://www.modpay.com) - US
* [Moneris](http://www.moneris.com/) - CA
+* [Moneris US](http://www.monerisusa.com/) - US
* [NABTransact](http://www.nab.com.au/nabtransact/) - AU
* [Netaxept](http://www.betalingsterminal.no/Netthandel-forside) - NO, DK, SE, FI
* [NetRegistry](http://www.netregistry.com.au) - AU
View
211 lib/active_merchant/billing/gateways/moneris_us.rb
@@ -0,0 +1,211 @@
+require 'rexml/document'
+
+module ActiveMerchant #:nodoc:
+ module Billing #:nodoc:
+
+ # To learn more about the Moneris (US) gateway, please contact
+ # ussales@moneris.com for a copy of their integration guide. For
+ # information on remote testing, please see "Test Environment Penny Value
+ # Response Table", and "Test Environment eFraud (AVS and CVD) Penny
+ # Response Values", available at Moneris' {eSelect Plus Documentation
+ # Centre}[https://www3.moneris.com/connect/en/documents/index.html].
+ class MonerisUsGateway < Gateway
+ TEST_URL = 'https://esplusqa.moneris.com/gateway_us/servlet/MpgRequest'
+ LIVE_URL = 'https://esplus.moneris.com/gateway_us/servlet/MpgRequest'
+
+ self.supported_countries = ['US']
+ self.supported_cardtypes = [:visa, :master, :american_express, :diners_club, :discover]
+ self.homepage_url = 'http://www.monerisusa.com/'
+ self.display_name = 'Moneris (US)'
+
+ # login is your Store ID
+ # password is your API Token
+ def initialize(options = {})
+ requires!(options, :login, :password)
+ @options = { :crypt_type => 7 }.update(options)
+ super
+ end
+
+ # Referred to as "PreAuth" in the Moneris integration guide, this action
+ # verifies and locks funds on a customer's card, which then must be
+ # captured at a later date.
+ #
+ # Pass in +order_id+ and optionally a +customer+ parameter.
+ def authorize(money, creditcard, options = {})
+ debit_commit 'us_preauth', money, creditcard, options
+ end
+
+ # This action verifies funding on a customer's card, and readies them for
+ # deposit in a merchant's account.
+ #
+ # Pass in <tt>order_id</tt> and optionally a <tt>customer</tt> parameter
+ def purchase(money, creditcard, options = {})
+ debit_commit 'us_purchase', money, creditcard, options
+ end
+
+ # This method retrieves locked funds from a customer's account (from a
+ # PreAuth) and prepares them for deposit in a merchant's account.
+ #
+ # Note: Moneris requires both the order_id and the transaction number of
+ # the original authorization. To maintain the same interface as the other
+ # gateways the two numbers are concatenated together with a ; separator as
+ # the authorization number returned by authorization
+ def capture(money, authorization, options = {})
+ commit 'us_completion', crediting_params(authorization, :comp_amount => amount(money))
+ end
+
+ # Voiding requires the original transaction ID and order ID of some open
+ # transaction. Closed transactions must be refunded. Note that the only
+ # methods which may be voided are +capture+ and +purchase+.
+ #
+ # Concatenate your transaction number and order_id by using a semicolon
+ # (';'). This is to keep the Moneris interface consistent with other
+ # gateways. (See +capture+ for details.)
+ def void(authorization, options = {})
+ commit 'us_purchasecorrection', crediting_params(authorization)
+ end
+
+ # Performs a refund. This method requires that the original transaction
+ # number and order number be included. Concatenate your transaction
+ # number and order_id by using a semicolon (';'). This is to keep the
+ # Moneris interface consistent with other gateways. (See +capture+ for
+ # details.)
+ def credit(money, authorization, options = {})
+ deprecated CREDIT_DEPRECATION_MESSAGE
+ refund(money, authorization, options)
+ end
+
+ def refund(money, authorization, options = {})
+ commit 'us_refund', crediting_params(authorization, :amount => amount(money))
+ end
+
+ def test?
+ @options[:test] || super
+ end
+ private # :nodoc: all
+
+ def expdate(creditcard)
+ sprintf("%.4i", creditcard.year)[-2..-1] + sprintf("%.2i", creditcard.month)
+ end
+
+ def debit_commit(commit_type, money, creditcard, options)
+ requires!(options, :order_id)
+ commit(commit_type, debit_params(money, creditcard, options))
+ end
+
+ # Common params used amongst the +purchase+ and +authorization+ methods
+ def debit_params(money, creditcard, options = {})
+ {
+ :order_id => options[:order_id],
+ :cust_id => options[:customer],
+ :amount => amount(money),
+ :pan => creditcard.number,
+ :expdate => expdate(creditcard),
+ :crypt_type => options[:crypt_type] || @options[:crypt_type]
+ }
+ end
+
+ # Common params used amongst the +credit+, +void+ and +capture+ methods
+ def crediting_params(authorization, options = {})
+ {
+ :txn_number => split_authorization(authorization).first,
+ :order_id => split_authorization(authorization).last,
+ :crypt_type => options[:crypt_type] || @options[:crypt_type]
+ }.merge(options)
+ end
+
+ # Splits an +authorization+ param and retrives the order id and
+ # transaction number in that order.
+ def split_authorization(authorization)
+ if authorization.nil? || authorization.empty? || authorization !~ /;/
+ raise ArgumentError, 'You must include a valid authorization code (e.g. "1234;567")'
+ else
+ authorization.split(';')
+ end
+ end
+
+ def commit(action, parameters = {})
+ response = parse(ssl_post(test? ? TEST_URL : LIVE_URL, post_data(action, parameters)))
+
+ Response.new(successful?(response), message_from(response[:message]), response,
+ :test => test?,
+ :authorization => authorization_from(response)
+ )
+ end
+
+ # Generates a Moneris authorization string of the form 'trans_id;receipt_id'.
+ def authorization_from(response = {})
+ if response[:trans_id] && response[:receipt_id]
+ "#{response[:trans_id]};#{response[:receipt_id]}"
+ end
+ end
+
+ # Tests for a successful response from Moneris' servers
+ def successful?(response)
+ response[:response_code] &&
+ response[:complete] &&
+ (0..49).include?(response[:response_code].to_i)
+ end
+
+ def parse(xml)
+ response = { :message => "Global Error Receipt", :complete => false }
+ hashify_xml!(xml, response)
+ response
+ end
+
+ def hashify_xml!(xml, response)
+ xml = REXML::Document.new(xml)
+ return if xml.root.nil?
+ xml.elements.each('//receipt/*') do |node|
+ response[node.name.underscore.to_sym] = normalize(node.text)
+ end
+ end
+
+ def post_data(action, parameters = {})
+ xml = REXML::Document.new
+ root = xml.add_element("request")
+ root.add_element("store_id").text = options[:login]
+ root.add_element("api_token").text = options[:password]
+ transaction = root.add_element(action)
+
+ # Must add the elements in the correct order
+ actions[action].each do |key|
+ transaction.add_element(key.to_s).text = parameters[key] unless parameters[key].blank?
+ end
+
+ xml.to_s
+ end
+
+ def message_from(message)
+ return 'Unspecified error' if message.blank?
+ message.gsub(/[^\w]/, ' ').split.join(" ").capitalize
+ end
+
+ # Make a Ruby type out of the response string
+ def normalize(field)
+ case field
+ when "true" then true
+ when "false" then false
+ when '', "null" then nil
+ else field
+ end
+ end
+
+ def actions
+ {
+ "us_purchase" => [:order_id, :cust_id, :amount, :pan, :expdate, :crypt_type],
+ "us_preauth" => [:order_id, :cust_id, :amount, :pan, :expdate, :crypt_type],
+ "us_refund" => [:order_id, :amount, :txn_number, :crypt_type],
+ "us_ind_refund" => [:order_id, :cust_id, :amount, :pan, :expdate, :crypt_type],
+ "us_completion" => [:order_id, :comp_amount, :txn_number, :crypt_type],
+ "us_purchasecorrection" => [:order_id, :txn_number, :crypt_type],
+ "us_cavv_purchase" => [:order_id, :cust_id, :amount, :pan, :expdate, :cavv],
+ "us_cavv_preauth" => [:order_id, :cust_id, :amount, :pan, :expdate, :cavv],
+ "us_batchcloseall" => [],
+ "us_opentotals" => [:ecr_number],
+ "us_batchclose" => [:ecr_number]
+ }
+ end
+ end
+ end
+end
View
4 test/fixtures.yml
@@ -168,6 +168,10 @@ moneris:
login: store1
password: yesguy
+moneris_us:
+ login: monusqa002
+ password: qatoken
+
#Working credentials, no need to replace
nab_transact:
login: ABC0001
View
84 test/remote/gateways/remote_moneris_us_test.rb
@@ -0,0 +1,84 @@
+require 'test_helper'
+
+class MonerisUsRemoteTest < Test::Unit::TestCase
+ def setup
+ Base.mode = :test
+
+ @gateway = MonerisUsGateway.new(fixtures(:moneris_us))
+ @amount = 100
+ @credit_card = credit_card('4242424242424242')
+ @options = {
+ :order_id => generate_unique_id,
+ :billing_address => address,
+ :description => 'Store Purchase'
+ }
+ end
+
+ def test_successful_purchase
+ assert response = @gateway.purchase(@amount, @credit_card, @options)
+ assert_success response
+ assert_equal 'Approved', response.message
+ assert_false response.authorization.blank?
+ end
+
+ def test_successful_authorization
+ response = @gateway.authorize(@amount, @credit_card, @options)
+ assert_success response
+ assert_false response.authorization.blank?
+ end
+
+ def test_failed_authorization
+ response = @gateway.authorize(105, @credit_card, @options)
+ assert_failure response
+ end
+
+ def test_successful_authorization_and_capture
+ response = @gateway.authorize(@amount, @credit_card, @options)
+ assert_success response
+ assert response.authorization
+
+ response = @gateway.capture(@amount, response.authorization)
+ assert_success response
+ end
+
+ def test_successful_authorization_and_void
+ response = @gateway.authorize(@amount, @credit_card, @options)
+ assert_success response
+ assert response.authorization
+
+ # Moneris cannot void a preauthorization
+ # You must capture the auth transaction with an amount of $0.00
+ void = @gateway.capture(0, response.authorization)
+ assert_success void
+ end
+
+ def test_successful_purchase_and_void
+ purchase = @gateway.purchase(@amount, @credit_card, @options)
+ assert_success purchase
+
+ void = @gateway.void(purchase.authorization)
+ assert_success void
+ end
+
+ def test_failed_purchase_and_void
+ purchase = @gateway.purchase(101, @credit_card, @options)
+ assert_failure purchase
+
+ void = @gateway.void(purchase.authorization)
+ assert_failure void
+ end
+
+ def test_successful_purchase_and_credit
+ purchase = @gateway.purchase(@amount, @credit_card, @options)
+ assert_success purchase
+
+ credit = @gateway.credit(@amount, purchase.authorization)
+ assert_success credit
+ end
+
+ def test_failed_purchase_from_error
+ assert response = @gateway.purchase(150, @credit_card, @options)
+ assert_failure response
+ assert_equal 'Declined', response.message
+ end
+end
View
3  test/unit/base_test.rb
@@ -11,7 +11,8 @@ def teardown
def test_should_return_a_new_gateway_specified_by_symbol_name
assert_equal BogusGateway, Base.gateway(:bogus)
- assert_equal MonerisGateway, Base.gateway(:moneris)
+ assert_equal MonerisGateway, Base.gateway(:moneris)
+ assert_equal MonerisUsGateway, Base.gateway(:moneris_us)
assert_equal AuthorizeNetGateway, Base.gateway(:authorize_net)
assert_equal UsaEpayGateway, Base.gateway(:usa_epay)
assert_equal LinkpointGateway, Base.gateway(:linkpoint)
View
172 test/unit/gateways/moneris_us_test.rb
@@ -0,0 +1,172 @@
+require 'test_helper'
+
+class MonerisUsTest < Test::Unit::TestCase
+ def setup
+ Base.mode = :test
+
+ @gateway = MonerisUsGateway.new(
+ :login => 'monusqa002',
+ :password => 'qatoken'
+ )
+
+ @amount = 100
+ @credit_card = credit_card('4242424242424242')
+ @options = { :order_id => '1', :billing_address => address }
+ end
+
+ def test_successful_purchase
+ @gateway.expects(:ssl_post).returns(successful_purchase_response)
+
+ assert response = @gateway.authorize(100, @credit_card, @options)
+ assert_success response
+ assert_equal '58-0_3;1026.1', response.authorization
+ end
+
+ def test_failed_purchase
+ @gateway.expects(:ssl_post).returns(failed_purchase_response)
+
+ assert response = @gateway.authorize(100, @credit_card, @options)
+ assert_failure response
+ end
+
+ def test_deprecated_credit
+ @gateway.expects(:ssl_post).with(anything, regexp_matches(/txn_number>123<\//), anything).returns("")
+ @gateway.expects(:parse).returns({})
+ assert_deprecation_warning(Gateway::CREDIT_DEPRECATION_MESSAGE, @gateway) do
+ @gateway.credit(@amount, "123;456", @options)
+ end
+ end
+
+ def test_refund
+ @gateway.expects(:ssl_post).with(anything, regexp_matches(/txn_number>123<\//), anything).returns("")
+ @gateway.expects(:parse).returns({})
+ @gateway.refund(@amount, "123;456", @options)
+ end
+
+ def test_amount_style
+ assert_equal '10.34', @gateway.send(:amount, 1034)
+
+ assert_raise(ArgumentError) do
+ @gateway.send(:amount, '10.34')
+ end
+ end
+
+ def test_purchase_is_valid_xml
+
+ params = {
+ :order_id => "order1",
+ :amount => "1.01",
+ :pan => "4242424242424242",
+ :expdate => "0303",
+ :crypt_type => 7,
+ }
+
+ assert data = @gateway.send(:post_data, 'us_preauth', params)
+ assert REXML::Document.new(data)
+ assert_equal xml_capture_fixture.size, data.size
+ end
+
+ def test_purchase_is_valid_xml
+
+ params = {
+ :order_id => "order1",
+ :amount => "1.01",
+ :pan => "4242424242424242",
+ :expdate => "0303",
+ :crypt_type => 7,
+ }
+
+ assert data = @gateway.send(:post_data, 'us_purchase', params)
+ assert REXML::Document.new(data)
+ assert_equal xml_purchase_fixture.size, data.size
+ end
+
+ def test_capture_is_valid_xml
+
+ params = {
+ :order_id => "order1",
+ :amount => "1.01",
+ :pan => "4242424242424242",
+ :expdate => "0303",
+ :crypt_type => 7,
+ }
+
+ assert data = @gateway.send(:post_data, 'us_preauth', params)
+ assert REXML::Document.new(data)
+ assert_equal xml_capture_fixture.size, data.size
+ end
+
+ def test_supported_countries
+ assert_equal ['US'], MonerisUsGateway.supported_countries
+ end
+
+ def test_supported_card_types
+ assert_equal [:visa, :master, :american_express, :diners_club, :discover], MonerisUsGateway.supported_cardtypes
+ end
+
+ def test_should_raise_error_if_transaction_param_empty_on_credit_request
+ [nil, '', '1234'].each do |invalid_transaction_param|
+ assert_raise(ArgumentError) { @gateway.void(invalid_transaction_param) }
+ end
+ end
+
+ private
+ def successful_purchase_response
+ <<-RESPONSE
+<?xml version="1.0"?>
+<response>
+ <receipt>
+ <ReceiptId>1026.1</ReceiptId>
+ <ReferenceNum>661221050010170010</ReferenceNum>
+ <ResponseCode>027</ResponseCode>
+ <ISO>01</ISO>
+ <AuthCode>013511</AuthCode>
+ <TransTime>18:41:13</TransTime>
+ <TransDate>2008-01-05</TransDate>
+ <TransType>00</TransType>
+ <Complete>true</Complete>
+ <Message>APPROVED * =</Message>
+ <TransAmount>1.00</TransAmount>
+ <CardType>V</CardType>
+ <TransID>58-0_3</TransID>
+ <TimedOut>false</TimedOut>
+ </receipt>
+</response>
+
+ RESPONSE
+ end
+
+ def failed_purchase_response
+ <<-RESPONSE
+<?xml version="1.0"?>
+<response>
+ <receipt>
+ <ReceiptId>1026.1</ReceiptId>
+ <ReferenceNum>661221050010170010</ReferenceNum>
+ <ResponseCode>481</ResponseCode>
+ <ISO>01</ISO>
+ <AuthCode>013511</AuthCode>
+ <TransTime>18:41:13</TransTime>
+ <TransDate>2008-01-05</TransDate>
+ <TransType>00</TransType>
+ <Complete>true</Complete>
+ <Message>DECLINED * =</Message>
+ <TransAmount>1.00</TransAmount>
+ <CardType>V</CardType>
+ <TransID>97-2-0</TransID>
+ <TimedOut>false</TimedOut>
+ </receipt>
+</response>
+
+ RESPONSE
+ end
+
+ def xml_purchase_fixture
+ '<request><store_id>monusqa002</store_id><api_token>qatoken</api_token><us_purchase><amount>1.01</amount><pan>4242424242424242</pan><expdate>0303</expdate><crypt_type>7</crypt_type><order_id>order1</order_id></us_purchase></request>'
+ end
+
+ def xml_capture_fixture
+ '<request><store_id>monusqa002</store_id><api_token>qatoken</api_token><us_preauth><amount>1.01</amount><pan>4242424242424242</pan><expdate>0303</expdate><crypt_type>7</crypt_type><order_id>order1</order_id></us_preauth></request>'
+ end
+
+end
Please sign in to comment.
Something went wrong with that request. Please try again.