Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
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.