Created
April 2, 2014 21:12
-
-
Save werkshy/9943274 to your computer and use it in GitHub Desktop.
Mock Authorize.net CIM Gateway (ActiveMerchant). Released under the MIT License
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Initializer where we inject the mock gateway into global variable AUTHNET_GATEWAY or setup the real client | |
if !Rails.env.production? | |
ActiveMerchant::Billing::Base.mode = :test | |
end | |
$using_mock_auth_net_gateway = false | |
# Use FORCE_AUTH_NET to use the real Auth.net API in tests | |
# Use MOCK_AUTH_NET to use the mock Auth.net API even in development (good on a plane!) | |
if Rails.env.test? && !ENV.has_key?("FORCE_AUTH_NET") || ENV.has_key?("MOCK_AUTH_NET") | |
MockAuthNetCimGateway::LOGGER.info "Test env: using mock auth net" | |
::AUTHNET_GATEWAY = MockAuthNetCimGateway.new | |
$using_mock_auth_net_gateway = true | |
else | |
::AUTHNET_GATEWAY = ActiveMerchant::Billing::AuthorizeNetCimGateway.new( | |
:login => ENV["AUTH_NET_LOGIN"], | |
:password => ENV["AUTH_NET_API_TOKEN"], | |
:test_requests => false | |
) | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Class that will mock out functionality of the AuthNet test sandbox for their CIM API. | |
class MockAuthNetCimGateway < ActiveMerchant::Billing::AuthorizeNetCimGateway | |
## Special Numbers | |
FAILURE_CREDIT_CARD_NUMBER = "4222222222222" # If this card is entered, we will reject the payment. | |
EXCEPTION_CREDIT_CARD_NUMBER = "4041378749540103" # if this card is entered, payments will throw exceptions | |
UNVOIDABLE_TRANSACTION_ID = "98712498798247" # If this transaction is voided, it will fail | |
## Redis key prefixes | |
CUSTOMER_PREFIX = "mockcim__customer__" | |
TRANSACTION_PREFIX = "mockcim__transaction__" | |
LOGGER = Harrys::LoggerFactory.get_logger('mock_gateway') | |
def initialize(options = {}) | |
LOGGER.level = Log4r::DEBUG | |
LOGGER.debug "Mock authnet gateway: initialize" | |
@redis_url = ENV["REDISCLOUD_URL"] || ENV["REDISTOGO_URL"] || "localhost:6379" | |
end | |
def redis | |
if @redis.nil? | |
if @redis_url.start_with?("redis://") | |
LOGGER.debug "New Redis from redis://" | |
@redis = Redis.new(url: @redis_url) | |
else | |
LOGGER.debug "New Redis from host/port" | |
host, port = @redis_url.split(":") | |
@redis = Redis.new(host: host, port: port) | |
end | |
else | |
# Force reconnect to deal with after-fork errors | |
#@redis.client.reconnect | |
end | |
return @redis | |
end | |
def reset | |
LOGGER.debug "MOCK: reset MockAuthNetCimGateway" | |
keys = redis.keys("#{CUSTOMER_PREFIX}*") | |
if !keys.empty? | |
ret = redis.del(keys) | |
LOGGER.debug("Deleted #{ret} customers") | |
end | |
keys = redis.keys("#{TRANSACTION_PREFIX}*") | |
if !keys.empty? | |
ret = redis.del(keys) | |
LOGGER.debug("Deleted #{ret} transactions") | |
end | |
end | |
# CIM API call | |
def create_customer_profile(options) | |
LOGGER.debug "MOCK: create_customer_profile #{options[:profile]}" | |
# Copied 'requires' from base class | |
requires!(options, :profile) | |
requires!(options[:profile], :email) unless options[:profile][:merchant_customer_id] || options[:profile][:description] | |
requires!(options[:profile], :description) unless options[:profile][:email] || options[:profile][:merchant_customer_id] | |
requires!(options[:profile], :merchant_customer_id) unless options[:profile][:description] || options[:profile][:email] | |
customer_profile_id = generate_profile_id | |
email = options[:profile][:email] | |
# Check for duplicate emails | |
if !get_customer_by_email(email).nil? | |
puts "Found duplicate:" | |
puts get_customer_by_email(email).inspect | |
return ActiveMerchant::Billing::Response.new(false, "duplicate record", {}) | |
end | |
profile = { | |
"customer_profile_id" => customer_profile_id, | |
"email" => options[:profile][:email], | |
"payment_profiles" => {} | |
} | |
set_customer(profile) | |
message = "ANDY WUZ ERE" | |
return ActiveMerchant::Billing::Response.new(true, message, profile) | |
end | |
# CIM API call | |
def get_customer_profile(options) | |
requires!(options, :customer_profile_id) | |
id = options[:customer_profile_id] | |
LOGGER.debug "MOCK: get_customer_profile: #{id}" | |
customer = get_customer(options[:customer_profile_id]) | |
# Authnet returns either a hash (if only one profile) or an array of profiles. | |
# Yeah, I'm not fond of that either. | |
payment_profiles = nil | |
if customer["payment_profiles"].keys.length == 1 | |
key = customer["payment_profiles"].keys[0] | |
LOGGER.debug "MOCK: found single payment profile: #{key}" | |
payment_profiles = mask_cc(customer["payment_profiles"][key]) | |
payment_profiles["customer_payment_profile_id"] = key | |
elsif customer["payment_profiles"].keys.length > 1 | |
LOGGER.debug "MOCK: found multiple (#{customer["payment_profiles"].keys.length}) payment profiles" | |
payment_profiles = [] | |
customer["payment_profiles"].each do |this_id, profile| | |
profile["customer_payment_profile_id"] = this_id | |
payment_profiles << mask_cc(profile) | |
end | |
end | |
response = { | |
"profile" => {} | |
} | |
if !payment_profiles.nil? | |
response = { | |
"profile" => { | |
"email" => customer["email"], | |
"payment_profiles" => payment_profiles | |
} | |
} | |
end | |
return ActiveMerchant::Billing::Response.new(true, "", response) | |
end | |
# CIM API call | |
def update_customer_profile(options) | |
requires!(options, :profile) | |
requires!(options[:profile], :customer_profile_id) | |
customer_id = options[:profile][:customer_profile_id] | |
LOGGER.debug "MOCK update_customer_profile: #{customer_id}" | |
customer = get_customer(customer_id) | |
if customer.nil? | |
return ActiveMerchant::Billing::Response.new(false, "customer not found", {}) | |
end | |
LOGGER.debug "Trying to update customer profile with #{options[:profile]}" | |
customer["email"] = options[:profile][:email] | |
set_customer(customer) | |
return ActiveMerchant::Billing::Response.new(true, "Andy wuz ere", customer) | |
end | |
# CIM API call | |
def delete_customer_profile(options) | |
requires!(options, :customer_profile_id) | |
key = "#{CUSTOMER_PREFIX}#{options[:customer_profile_id]}" | |
if redis.exists(key) | |
redis.del(key) | |
return ActiveMerchant::Billing::Response.new(true, "", {}) | |
else | |
return ActiveMerchant::Billing::Response.new(false, "notfound", {}) | |
end | |
end | |
# CIM API call | |
def create_customer_payment_profile(options) | |
LOGGER.debug "MOCK: create_customer_payment_profile: #{options.inspect}" | |
requires!(options, :customer_profile_id) | |
requires!(options, :payment_profile) | |
requires!(options[:payment_profile], :payment) | |
payment_profile_id = generate_profile_id | |
set_payment_profile(options[:customer_profile_id], payment_profile_id, options[:payment_profile]) | |
response = { | |
"customer_payment_profile_id" => payment_profile_id | |
} | |
return ActiveMerchant::Billing::Response.new(true, "", response) | |
end | |
# CIM API call | |
def update_customer_payment_profile(options) | |
requires!(options, :customer_profile_id, :payment_profile) | |
requires!(options[:payment_profile], :customer_payment_profile_id) | |
profile_id = options[:payment_profile][:customer_payment_profile_id].to_s | |
LOGGER.debug "MOCK: update_customer_payment_profile: #{profile_id}" | |
payment_profile = options[:payment_profile] | |
begin | |
set_payment_profile(options[:customer_profile_id], profile_id, payment_profile) | |
response = { | |
"customer_payment_profile_id" => profile_id | |
} | |
return ActiveMerchant::Billing::Response.new(true, "", response) | |
rescue Exception => e | |
LOGGER.error "Exception updating billing profile: #{e}" | |
return ActiveMerchant::Billing::Response.new(false, "#{e}", {}) | |
end | |
end | |
# CIM API call | |
def delete_customer_payment_profile(options) | |
requires!(options, :customer_profile_id) | |
requires!(options, :customer_payment_profile_id) | |
payment_profile_id = options[:customer_payment_profile_id].to_s | |
LOGGER.debug "MOCK: delete customer payment profile #{payment_profile_id}" | |
customer = get_customer(options[:customer_profile_id]) | |
if !customer["payment_profiles"].has_key?(payment_profile_id) | |
return ActiveMerchant::Billing::Response.new(false, "Not found", {}) | |
end | |
customer["payment_profiles"].delete(payment_profile_id) | |
set_customer(customer) | |
return ActiveMerchant::Billing::Response.new(true, "Andy wuz ere", {}) | |
end | |
# CIM API call | |
def create_customer_profile_transaction(options) | |
requires!(options, :transaction) | |
requires!(options[:transaction], :type) | |
case options[:transaction][:type] | |
when :void | |
return void_transaction(options) | |
when :refund | |
return refund_transaction(options) | |
when :prior_auth_capture | |
return prior_auth_capture(options) | |
else | |
return auth_capture(options) | |
end | |
end | |
# Method for inspecting the mock | |
def get_transaction_by_invoice_number(invoice_number) | |
redis.keys("#{TRANSACTION_PREFIX}*").each do |key| | |
transaction = JSON.parse(redis.get(key)) | |
if transaction["invoice_number"] == invoice_number | |
return transaction | |
end | |
end | |
return nil | |
end | |
def get_customer_by_email(email) | |
redis.keys("#{CUSTOMER_PREFIX}*").each do |key| | |
customer = JSON.parse(redis.get(key)) | |
if customer["email"] == email | |
return customer | |
end | |
end | |
return nil | |
end | |
private | |
# Implement ':void' in customer profile transaction | |
def void_transaction(options) | |
requires!(options[:transaction], :trans_id) | |
id = options[:transaction][:trans_id] | |
LOGGER.debug "MOCK: voiding transaction #{id}" | |
# Verify that transaction exists and is not already voided | |
if id == UNVOIDABLE_TRANSACTION_ID | |
return ActiveMerchant::Billing::Response.new(false, "Forced void failure", {}) | |
end | |
transaction = get_transaction(id) | |
# Verify that transaction exists and is not already voided | |
if transaction.nil? | |
return ActiveMerchant::Billing::Response.new(false, "Transaction not found", {}) | |
end | |
if transaction["void"] == true | |
return ActiveMerchant::Billing::Response.new(true, "This transaction has already been voided", {}) | |
end | |
transaction["void"] = true | |
return ActiveMerchant::Billing::Response.new(true, "This transaction has been voided,void,blah blah blah", {}) | |
end | |
# Handle ':refund' in customer profile transaction (not used in our unit tests) | |
def refund_transaction(options) | |
requires!(options[:transaction], :trans_id) && ( | |
(options[:transaction][:customer_profile_id] && options[:transaction][:customer_payment_profile_id]) || | |
options[:transaction][:credit_card_number_masked] || | |
(options[:transaction][:bank_routing_number_masked] && options[:transaction][:bank_account_number_masked]) | |
) | |
# We (Harry's) don't use this in unit tests, because you can only refund after settlement | |
raise "refunds not implemented" | |
end | |
# Handle ':prior_auth_capture' in customer profile transaction (not used in our unit tests) | |
def prior_auth_capture(options) | |
# We (Harry's) don't use this, sorry | |
raise "prior auth capture not implemented" | |
end | |
# Handle ':auth_capture' in customer profile transaction i.e. actually charge user | |
def auth_capture(options) | |
requires!(options[:transaction], :amount, :customer_profile_id, :customer_payment_profile_id) | |
customer_profile_id = options[:transaction][:customer_profile_id] | |
payment_profile_id = options[:transaction][:customer_payment_profile_id].to_s | |
amount = options[:transaction][:amount] | |
invoice_number = options[:transaction][:order][:invoice_number] | |
LOGGER.debug "MOCK auth_capture transaction for $#{amount}" | |
# Check that customer exists | |
customer = get_customer(customer_profile_id) | |
if customer.nil? | |
return ActiveMerchant::Billing::Response.new(false, "Customer not found", {}) | |
end | |
# Check that billing profile exists | |
payment_profile = customer["payment_profiles"][payment_profile_id] | |
if payment_profile.nil? | |
return ActiveMerchant::Billing::Response.new(false, "Payment profile not found #{payment_profile_id}", {}) | |
end | |
if payment_profile["payment"]["credit_card"]["card_number"] == FAILURE_CREDIT_CARD_NUMBER | |
LOGGER.debug "MOCK: failure cc number detected: returning error" | |
return ActiveMerchant::Billing::Response.new(false, "Forced test failure", {}) | |
end | |
# Save transaction | |
transaction_id = generate_profile_id | |
transaction = { | |
"id" => transaction_id, | |
"amount" => amount, | |
"customer_profile_id" => customer_profile_id, | |
"customer_payment_profile_id" => payment_profile_id, | |
"invoice_number" => invoice_number, | |
"void" => false | |
} | |
set_transaction(transaction_id, transaction) | |
if payment_profile["payment"]["credit_card"]["card_number"] == EXCEPTION_CREDIT_CARD_NUMBER | |
LOGGER.debug "MOCK: exception cc number detected: throwing exception" | |
raise Exception.new("FORCED EXCEPTION IN AUTH NET MOCK") | |
end | |
params = { | |
"direct_response" => { | |
"transaction_id" => transaction_id, | |
"raw" => "Andy wuz ere" | |
} | |
} | |
return ActiveMerchant::Billing::Response.new(true, "", params) | |
end | |
# Helper method for finding customers in fake gateway store | |
def get_customer(customer_id) | |
customer = redis.get("#{CUSTOMER_PREFIX}#{customer_id}") | |
if !customer.nil? | |
customer = JSON.parse(customer) | |
end | |
return customer | |
end | |
def set_customer(customer) | |
redis.set("#{CUSTOMER_PREFIX}#{customer["customer_profile_id"]}", customer.to_json) | |
end | |
def get_transaction(transaction_id) | |
transaction = redis.get("#{TRANSACTION_PREFIX}#{transaction_id}") | |
if !transaction.nil? | |
transaction = JSON.parse(transaction) | |
end | |
return transaction | |
end | |
def set_transaction(transaction_id, transaction) | |
redis.set("#{TRANSACTION_PREFIX}#{transaction_id}", transaction.to_json) | |
end | |
def find_transaction_by_invoice_number(invoice_number) | |
end | |
# Helper method for storing payment profiles | |
def set_payment_profile(customer_id, payment_profile_id, payment_profile) | |
customer = get_customer(customer_id) | |
payment_profile = payment_profile.with_indifferent_access | |
LOGGER.debug "Setting payment profile #{payment_profile_id}: #{payment_profile.inspect}" | |
if customer["payment_profiles"].has_key?(payment_profile_id) | |
customer["payment_profiles"][payment_profile_id] = update_payment_profile( | |
customer["payment_profiles"][payment_profile_id], | |
payment_profile) | |
else | |
if payment_profile.has_key?("payment") | |
payment_profile["payment"]["credit_card"] = cc_to_hash(payment_profile["payment"]["credit_card"]) | |
end | |
customer["payment_profiles"][payment_profile_id] = payment_profile | |
end | |
set_customer(customer) | |
end | |
# Helper method for selectively updating payment profiles | |
def update_payment_profile(existing_profile, new_profile) | |
profile = {} | |
if new_profile.has_key?("bill_to") | |
profile["bill_to"] = new_profile["bill_to"] | |
elsif existing_profile.has_key?("bill_to") | |
profile["bill_to"] = existing_profile["bill_to"] | |
end | |
# If the user included the credit card, we'll set it | |
if new_profile.has_key?("payment") | |
new_profile["payment"]["credit_card"] = cc_to_hash(new_profile["payment"]["credit_card"]) | |
profile["payment"] = new_profile["payment"] | |
new_number = new_profile["payment"]["credit_card"]["card_number"] | |
existing_number = existing_profile["payment"]["credit_card"]["card_number"] | |
# But if the user send a masked credit card, we'll check it's correct, then keep the existing one. | |
if new_number[0] == "X" | |
LOGGER.debug "Found blanked CC" | |
if existing_number[-4..-1] != new_number[-4..-1] | |
raise "does not match the original value" | |
end | |
profile["payment"]["credit_card"]["card_number"] = existing_number | |
end | |
elsif existing_profile.has_key?("payment") | |
profile["payment"] = existing_profile["payment"] | |
end | |
return profile | |
end | |
# Convert a ActiveMervchant credit card object to a hash (which is what we store and return) | |
def cc_to_hash(credit_card) | |
if credit_card.is_a?(Hash) | |
return credit_card | |
end | |
if credit_card.nil? | |
return nil | |
end | |
LOGGER.debug "Converting CC to hash: #{credit_card.inspect}" | |
hash = { | |
"card_number" => credit_card.number, | |
"expiration_date" => credit_card.year.to_s + credit_card.month.to_s | |
} | |
return hash | |
end | |
# When returning the credit card, we mask the number and expiration | |
def mask_cc(payment_profile) | |
payment_profile = payment_profile.with_indifferent_access | |
masked = payment_profile.deep_dup | |
if masked["payment"].nil? | |
return masked | |
end | |
cc = cc_to_hash(masked["payment"]["credit_card"]) | |
if !cc.nil? | |
masked["payment"]["credit_card"] = { | |
"card_number" => "XXXX" + cc["card_number"][-4..-1], | |
"expiration_date" => "XXXX" } | |
end | |
return masked | |
end | |
# Generate realistic-looking auth.net customer profile ids | |
def generate_profile_id(length=9) | |
num = SecureRandom.random_number(10**length - 1) | |
return (num + 1).to_s | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Just some snippets of testing and examing mock data | |
... | |
before do | |
# Normal user | |
@user = FactoryGirl.create(:american) | |
# User with the 'throw an exception' credit card number | |
@exception_user = FactoryGirl.create(:user_with_exception_billing) | |
end | |
it "should handle exception in charge" do | |
if !$using_mock_auth_net_gateway | |
logger.warn "Cannot test exceptions without mock gateway" | |
end | |
# Create an order which will throw an exception when we charge it | |
order = FactoryGirl.build(:order, user: @exception_user) | |
expect { | |
ActiveRecord::Base.transaction do | |
logger.info "Starting transaction" | |
order.save! | |
end | |
}.to raise_error(Exception) | |
# Look up the transaction in our mock data | |
transaction = AUTHNET_GATEWAY.get_transaction_by_invoice_number(order.public_id) | |
# Hmmm, actually not much to check since the job isn't running due to resque mock | |
transaction["amount"].to_s.should eql "%0.2f" % order.total | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Snippet from rspec setup: wipe the mock data before each test | |
RSpec.configure do |config| | |
config.before(:each) do | |
# Wipe the auth.net mock data to avoid O(N) slowdown looking for existing email addresses | |
::AUTHNET_GATEWAY.reset if $using_mock_auth_net_gateway | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment