From 15fae40b60710b1cd53612341f6a570fd169abe2 Mon Sep 17 00:00:00 2001 From: Dave Lee Date: Fri, 24 Jun 2011 10:20:29 -0600 Subject: [PATCH] Implement Beanstream recurring paymentments Original code: neerajkumar/active_merchant Changes made: * Remove profile reporting * Remove old and seemingly defunct profile reporting code * Remove unused recurring_response_notification * Refactor recurring API to match other ActiveMerchant implementations * #new takes :recurring_api_key for recurring authentication * #recurring takes :periodicity option, as well as :interval as either a Hash or as an ActiveSupport::Duration (1.month). * #recurring takes :occurences or :payments option and is used to calculate an expiry date Special thanks to `git diff --patience -U1` and `patch -l -F2` --- .../billing/gateways/beanstream.rb | 30 ++++- .../gateways/beanstream/beanstream_core.rb | 108 +++++++++++++++++- test/fixtures.yml | 1 + .../remote/gateways/remote_beanstream_test.rb | 31 +++++ test/unit/gateways/beanstream_test.rb | 55 ++++++++- 5 files changed, 220 insertions(+), 5 deletions(-) diff --git a/lib/active_merchant/billing/gateways/beanstream.rb b/lib/active_merchant/billing/gateways/beanstream.rb index bb66f8d75fc..17c9a210d8a 100644 --- a/lib/active_merchant/billing/gateways/beanstream.rb +++ b/lib/active_merchant/billing/gateways/beanstream.rb @@ -19,7 +19,6 @@ module Billing #:nodoc: # To learn more about storing credit cards with the Beanstream gateway, please read the BEAN_Payment_Profiles.pdf (I had to phone BeanStream to request it.) # # == Notes - # * Recurring billing is not yet implemented. # * Adding of order products information is not implemented. # * Ensure that country and province data is provided as a code such as "CA", "US", "QC". # * login is the Beanstream merchant ID, username and password should be enabled in your Beanstream account and passed in using the :user and :password options. @@ -95,6 +94,35 @@ def void(authorization, options = {}) commit(post) end + def recurring(money, source, options = {}) + post = {} + add_amount(post, money) + add_invoice(post, options) + add_credit_card(post, source) + add_address(post, options) + add_transaction_type(post, purchase_action(source)) + add_recurring_type(post, options) + commit(post) + end + + def update_recurring(amount, source, options = {}) + post = {} + add_recurring_amount(post, amount) + add_recurring_invoice(post, options) + add_credit_card(post, source) + add_address(post, options) + add_recurring_operation_type(post, :update) + add_recurring_service(post, options) + recurring_commit(post) + end + + def cancel_recurring(options = {}) + post = {} + add_recurring_operation_type(post, :cancel) + add_recurring_service(post, options) + recurring_commit(post) + end + def interac @interac ||= BeanstreamInteracGateway.new(@options) end diff --git a/lib/active_merchant/billing/gateways/beanstream/beanstream_core.rb b/lib/active_merchant/billing/gateways/beanstream/beanstream_core.rb index 973d4612415..1e2293ea916 100644 --- a/lib/active_merchant/billing/gateways/beanstream/beanstream_core.rb +++ b/lib/active_merchant/billing/gateways/beanstream/beanstream_core.rb @@ -2,6 +2,7 @@ module ActiveMerchant #:nodoc: module Billing #:nodoc: module BeanstreamCore URL = 'https://www.beanstream.com/scripts/process_transaction.asp' + RECURRING_URL = 'https://www.beanstream.com/scripts/recurring_billing.asp' SECURE_PROFILE_URL = 'https://www.beanstream.com/scripts/payment_profile.asp' SP_SERVICE_VERSION = '1.1' @@ -37,6 +38,27 @@ module BeanstreamCore '9' => 'I' } + PERIODS = { + :days => 'D', + :weeks => 'W', + :months => 'M', + :years => 'Y' + } + + PERIODICITIES = { + :daily => [:days, 1], + :weekly => [:weeks, 1], + :biweekly => [:weeks, 2], + :monthly => [:months, 1], + :bimonthly => [:months, 2], + :yearly => [:years, 1] + } + + RECURRING_OPERATION = { + :update => 'M', + :cancel => 'C' + } + def self.included(base) base.default_currency = 'CAD' @@ -203,11 +225,66 @@ def add_secure_profile_variables(post, options = {}) post[:status] = options[:status] end + def add_recurring_amount(post, money) + post[:amount] = amount(money) + end + + def add_recurring_invoice(post, options) + post[:rbApplyTax1] = options[:apply_tax1] + end + + def add_recurring_operation_type(post, operation) + post[:operationType] = RECURRING_OPERATION[operation] + end + + def add_recurring_service(post, options) + post[:serviceVersion] = '1.0' + post[:merchantId] = @options[:login] + post[:passCode] = @options[:recurring_api_key] + post[:rbAccountId] = options[:account_id] + end + + def add_recurring_type(post, options) + # XXX requires! + post[:trnRecurring] = 1 + period, increment = interval(options) + post[:rbBillingPeriod] = PERIODS[period] + post[:rbBillingIncrement] = increment + + if options.include? :start_date + post[:rbCharge] = 0 + post[:rbFirstBilling] = options[:start_date].strftime('%m%d%Y') + end + + if count = options[:occurrences] || options[:payments] + post[:rbExpiry] = (options[:start_date] || Date.current).advance(period => count).strftime('%m%d%Y') + end + end + + def interval(options) + if options.include? :periodicity + requires!(options, [:periodicity, *PERIODICITIES.keys]) + PERIODICITIES[options[:periodicity]] + elsif options.include? :interval + interval = options[:interval] + if interval.respond_to? :parts + parts = interval.parts + raise ArgumentError.new("Cannot recur with mixed interval (#{interval}). Use only one of: days, weeks, months or years") if parts.length > 1 + parts.first + elsif interval.kind_of? Hash + requires!(interval, :unit) + unit, length = interval.values_at(:unit, :length) + length ||= 1 + [unit, length] + end + end + end + def parse(body) results = {} if !body.nil? body.split(/&/).each do |pair| - key,val = pair.split(/=/) + key, val = pair.split(/=/) results[key.to_sym] = val.nil? ? nil : CGI.unescape(val) end end @@ -221,11 +298,22 @@ def parse(body) results end - + + def recurring_parse(data) + REXML::Document.new(data).root.elements.to_a.inject({}) do |response, element| + response[element.name.to_sym] = element.text + response + end + end + def commit(params, use_profile_api = false) post(post_data(params,use_profile_api),use_profile_api) end + def recurring_commit(params) + recurring_post(post_data(params, false)) + end + def post(data, use_profile_api=nil) response = parse(ssl_post((use_profile_api ? SECURE_PROFILE_URL : URL), data)) response[:customer_vault_id] = response[:customerCode] if response[:customerCode] @@ -236,7 +324,12 @@ def post(data, use_profile_api=nil) :avs_result => { :code => (AVS_CODES.include? response[:avsId]) ? AVS_CODES[response[:avsId]] : response[:avsId] } ) end - + + def recurring_post(data) + response = recurring_parse(ssl_post(RECURRING_URL, data)) + build_response(recurring_success?(response), recurring_message_from(response), response) + end + def authorization_from(response) "#{response[:trnId]};#{response[:trnAmount]};#{response[:trnType]}" end @@ -245,10 +338,18 @@ def message_from(response) response[:messageText] || response[:responseMessage] end + def recurring_message_from(response) + response[:message] + end + def success?(response) response[:responseType] == 'R' || response[:trnApproved] == '1' || response[:responseCode] == '1' end + def recurring_success?(response) + response[:code] == '1' + end + def add_source(post, source) if source.is_a?(String) or source.is_a?(Integer) post[:customerCode] = source @@ -276,6 +377,7 @@ def post_data(params, use_profile_api) params.reject{|k, v| v.blank?}.collect { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join("&") end + end end end diff --git a/test/fixtures.yml b/test/fixtures.yml index b8ba3ff0877..e70cb573066 100644 --- a/test/fixtures.yml +++ b/test/fixtures.yml @@ -24,6 +24,7 @@ beanstream: user: username password: password secure_profile_api_key: API Access Passcode + recurring_api_key: API Access Passcode beanstream_interac: login: merchant id diff --git a/test/remote/gateways/remote_beanstream_test.rb b/test/remote/gateways/remote_beanstream_test.rb index 39d2fe684c5..be7b3ebca33 100644 --- a/test/remote/gateways/remote_beanstream_test.rb +++ b/test/remote/gateways/remote_beanstream_test.rb @@ -47,6 +47,10 @@ def setup :tax2 => 100, :custom => 'reference one' } + + @recurring_options = @options.merge( + :interval => { :unit => :months, :length => 1 }, + :occurences => 5) end def test_successful_visa_purchase @@ -140,6 +144,33 @@ def test_successful_check_purchase_and_credit assert_success credit end + def test_successful_recurring + assert response = @gateway.recurring(@amount, @visa, @recurring_options) + assert_success response + assert response.test? + assert_false response.authorization.blank? + end + + def test_successful_update_recurring + assert response = @gateway.recurring(@amount, @visa, @recurring_options) + assert_success response + assert response.test? + assert_false response.authorization.blank? + + assert response = @gateway.update_recurring(@amount + 500, @visa, @recurring_options.merge(:account_id => response.params["rbAccountId"])) + assert_success response + end + + def test_successful_cancel_recurring + assert response = @gateway.recurring(@amount, @visa, @recurring_options) + assert_success response + assert response.test? + assert_false response.authorization.blank? + + assert response = @gateway.cancel_recurring(:account_id => response.params["rbAccountId"]) + assert_success response + end + def test_invalid_login gateway = BeanstreamGateway.new( :merchant_id => '', diff --git a/test/unit/gateways/beanstream_test.rb b/test/unit/gateways/beanstream_test.rb index 6f35a4d0f38..536feba20a8 100644 --- a/test/unit/gateways/beanstream_test.rb +++ b/test/unit/gateways/beanstream_test.rb @@ -38,6 +38,10 @@ def setup :tax2 => 100, :custom => 'reference one' } + + @recurring_options = @options.merge( + :interval => { :unit => :months, :length => 1 }, + :occurrences => 5) end def test_successful_purchase @@ -115,7 +119,42 @@ def test_brazilian_address_sets_state_and_zip_to_the_required_dummy_values @gateway.purchase(@amount, @credit_card, @options) end - + def test_successful_recurring + @gateway.expects(:ssl_post).returns(successful_recurring_response) + + assert response = @gateway.recurring(@amount, @credit_card, @recurring_options) + assert_success response + assert_equal 'Approved', response.message + end + + def test_successful_update_recurring + @gateway.expects(:ssl_post).returns(successful_recurring_response) + + assert response = @gateway.recurring(@amount, @credit_card, @recurring_options) + assert_success response + assert_equal 'Approved', response.message + + @gateway.expects(:ssl_post).returns(successful_update_recurring_response) + + assert response = @gateway.update_recurring(@amount, @credit_card, @recurring_options.merge(:account_id => response.params["rbAccountId"])) + assert_success response + assert_equal "Request successful", response.message + end + + def test_successful_cancel_recurring + @gateway.expects(:ssl_post).returns(successful_recurring_response) + + assert response = @gateway.recurring(@amount, @credit_card, @recurring_options) + assert_success response + assert_equal 'Approved', response.message + + @gateway.expects(:ssl_post).returns(successful_cancel_recurring_response) + + assert response = @gateway.cancel_recurring(:account_id => response.params["rbAccountId"]) + assert_success response + assert_equal "Request successful", response.message + end + private def successful_purchase_response @@ -145,4 +184,18 @@ def german_address_params_without_state def next_year (Time.now.year + 1).to_s[/\d\d$/] end + + def successful_recurring_response + "trnApproved=1&trnId=10000072&messageId=1&messageText=Approved&trnOrderNumber=5d9f511363a0f35d37de53b4d74f5b&authCode=&errorType=N&errorFields=&responseType=T&trnAmount=15%2E00&trnDate=6%2F4%2F2008+6%3A33%3A55+PM&avsProcessed=0&avsId=0&avsResult=0&avsAddrMatch=0&avsPostalMatch=0&avsMessage=Address+Verification+not+performed+for+this+transaction%2E&trnType=D&paymentMethod=EFT&ref1=reference+one&ref2=&ref3=&ref4=&ref5=" + end + + def successful_update_recurring_response + "1Request successful" + end + + def successful_cancel_recurring_response + "1Request successful" + end + end +