require 'json'
module ActiveMerchant #:nodoc:
module Billing #:nodoc:
# For more information on Balanced visit https://www.balancedpayments.com
# or visit #balanced on irc.freenode.net
#
# Instantiate a instance of BalancedGateway by passing through your
# Balanced API key secret.
#
# ==== To obtain an API key of your own
#
# 1. Visit https://www.balancedpayments.com
# 2. Click "Get started"
# 3. The next screen will give you a test API key of your own
# 4. When you're ready to generate a production API key click the "Go
# live" button on the Balanced dashboard and fill in your marketplace
# details.
#
# ==== Overview
#
# Balanced provides a RESTful API, all entities within Balanced are
# represented by their respective URIs, these are returned in the
# `authorization` parameter of the Active Merchant Response object.
#
# All Response objects will contain a hash property called `params` which
# holds the raw JSON dictionary returned by Balanced. You can find
# properties about the operation performed and the object that represents
# it within this hash.
#
# All operations within Balanced are tied to an account, as such, when you
# perform an `authorization` or a `capture` with a new credit card you
# must ensure you also pass the `:email` property within the `options`
# parameter.
#
# For more details about Balanced's API visit:
# https://www.balancedpayments.com/docs
#
# ==== Terminology & Transaction Flow
#
# * An `authorization` operation will return a Hold URI. An `authorization`
# within Balanced is valid until the `expires_at` property. You can see the
# exact date of the expiry on the Response object by inspecting the
# property `response.params['expires_at']`. The resulting Hold may be
# `capture`d or `void`ed at any time before the `expires_at` date for
# any amount up to the full amount of the original `authorization`.
# * A `capture` operation will return a Debit URI. You must pass the URI of
# the previously performed `authorization`
# * A `purchase` will create a Hold and Debit in a single operation and
# return the URI of the resulting Debit.
# * A `void` operation must be performed on an existing `authorization`
# and will result in releasing the funds reserved by the
# `authorization`.
# * The `refund` operation must be performed on a previously captured
# Debit URI. You may refund any fraction of the original amount of the
# debit up to the original total.
#
class BalancedGateway < Gateway
VERSION = '1.0.0'
TEST_URL = LIVE_URL = 'https://api.balancedpayments.com'
# The countries the gateway supports merchants from as 2 digit ISO
# country codes
self.supported_countries = ['US']
self.supported_cardtypes = [:visa, :master, :american_express, :discover]
self.homepage_url = 'https://www.balancedpayments.com/'
self.display_name = 'Balanced'
self.money_format = :cents
class Error < StandardError
attr_reader :response
def initialize(response, msg=nil)
@response = response
super(msg || response['description'])
end
end
class CardDeclined < Error
end
# Creates a new BalancedGateway
#
# The gateway requires that a valid api_key be passed in the +options+
# hash.
#
# ==== Options
#
# * :login -- The Balanced API Secret (REQUIRED)
def initialize(options = {})
requires!(options, :login)
super
initialize_marketplace(options[:marketplace] || load_marketplace)
end
# Performs an authorization (Hold in Balanced nonclementure), which
# reserves the funds on the customer's credit card, but does not charge
# the card. An authorization is valid until the `expires_at` field in
# the params Hash passes. See `response.params['expires_at']`. The exact
# amount of time until an authorization expires depends on the card
# issuer.
#
# If you pass a previously tokenized `credit_card` URI the only other
# parameter required is `money`. If you pass `credit_card` as a hash of
# credit card information you must also pass `options` with a `:email`
# entry.
#
# ==== Parameters
#
# * money -- The amount to be authorized as an Integer value in cents.
# * credit_card -- A hash of credit card details for this
# transaction or the URI of a card previously stored in Balanced.
# * options -- A hash of optional parameters.
#
# ==== Options
#
# If you are passing a new credit card you must pass one of these two
# parameters
#
# * email -- the email address of user associated with this
# purchase.
# * account_uri -- `account_uri` is the URI of an existing
# Balanced account.
def authorize(money, credit_card, options = {})
if credit_card.respond_to?(:number)
requires!(options, :email) unless options[:account_uri]
end
post = {}
post[:amount] = money
post[:description] = options[:description]
create_or_find_account(post, options)
add_credit_card(post, credit_card, options)
add_address(credit_card, options)
create_transaction(:post, @holds_uri, post)
rescue Error => ex
failed_response(ex.response)
end
# Perform a purchase, which is an authorization and capture in a single
# operation.
#
# ==== Parameters
#
# * money -- The amount to be purchased as an Integer value in cents.
# * credit_card -- A hash of credit card details for this
# transaction or the URI of a card previously stored in Balanced.
# * options -- A hash of optional parameters.
#
# ==== Options
#
# If you are passing a new credit card you must pass one of these two
# parameters
#
# * email -- the email address of user associated with this
# purchase.
# * account_uri -- `account_uri` is the URI of an existing
# Balanced account.
def purchase(money, credit_card, options = {})
if credit_card.respond_to?('number')
requires!(options, :email) unless options[:account_uri]
end
post = {}
post[:amount] = money
post[:description] = options[:description]
create_or_find_account(post, options)
add_credit_card(post, credit_card, options)
add_address(credit_card, options)
create_transaction(:post, @debits_uri, post)
rescue Error => ex
failed_response(ex.response)
end
# Captures the funds from an authorized transaction (Hold).
#
# ==== Parameters
#
# * money -- The amount to be captured as an Integer value in
# cents. If omitted the full amount of the original authorization
# transaction will be captured.
# * authorization -- The uri of an authorization returned from
# an authorize request.
#
# ==== Options
#
# * description -- A string that will be displayed on the
# Balanced dashboard
def capture(money, authorization, options = {})
post = {}
post[:hold_uri] = authorization
post[:amount] = money if money
post[:description] = options[:description] if options[:description]
post[:on_behalf_of_uri] = options[:on_behalf_of_uri] if options[:on_behalf_of_uri]
create_transaction(:post, @debits_uri, post)
rescue Error => ex
failed_response(ex.response)
end
# Void a previous authorization (Hold)
#
# ==== Parameters
#
# * authorization -- The uri of the authorization returned from
# an `authorize` request.
def void(authorization)
post = {}
post[:is_void] = true
create_transaction(:put, authorization, post)
rescue Error => ex
failed_response(ex.response)
end
# Refund a transaction.
#
# Returns the money debited from a card to the card from the
# marketplace's escrow balance.
#
# ==== Parameters
#
# * debit_uri -- The uri of the original transaction against
# which the refund is being issued.
# * options -- A hash of parameters.
#
# ==== Options
#
# * `:amount` -- specify an amount if you want to perform a
# partial refund. This value will default to the total amount of the
# debit that has not been refunded so far.
def refund(amount, debit_uri = "deprecated", options = {})
if(debit_uri == "deprecated" || debit_uri.kind_of?(Hash))
deprecated "Calling the refund method without an amount parameter is deprecated and will be removed in a future version."
return refund(options[:amount], amount, options)
end
requires!(debit_uri)
post = {}
post[:debit_uri] = debit_uri
post[:amount] = amount
post[:description] = options[:description]
create_transaction(:post, @refunds_uri, post)
rescue Error => ex
failed_response(ex.response)
end
# Stores a card and email address
#
# ==== Parameters
#
# * credit_card --
def store(credit_card, options = {})
requires!(options, :email)
post = {}
account_uri = create_or_find_account(post, options)
if credit_card.respond_to? :number
add_credit_card(post, credit_card, options)
else
associate_card_to_account(account_uri, credit_card)
credit_card
end
rescue Error => ex
failed_response(ex.response)
end
private
# Load URIs for this marketplace by inspecting the marketplace object
# returned from the uri. http://en.wikipedia.org/wiki/HATEOAS
def load_marketplace
response = http_request(:get, '/v1/marketplaces')
if error?(response)
raise Error.new(response, 'Invalid login credentials supplied')
end
response['items'][0]
end
def initialize_marketplace(marketplace)
@marketplace_uri = marketplace['uri']
@holds_uri = marketplace['holds_uri']
@debits_uri = marketplace['debits_uri']
@cards_uri = marketplace['cards_uri']
@accounts_uri = marketplace['accounts_uri']
@refunds_uri = marketplace['refunds_uri']
end
def create_or_find_account(post, options)
account_uri = nil
if options.has_key? :account_uri
account_uri = options[:account_uri]
end
if account_uri == nil
post[:email_address] = options[:email]
# create an account
response = http_request(:post, @accounts_uri, post)
if response.has_key? 'uri'
account_uri = response['uri']
elsif error?(response)
# lookup account from Balanced, account_uri should be in the
# exception in a dictionary called extras
account_uri = response['extras']['account_uri']
raise Error.new(response) unless account_uri
end
end
post[:account_uri] = account_uri
account_uri
end
def add_address(credit_card, options)
return unless credit_card.kind_of?(Hash)
if address = options[:billing_address] || options[:address]
credit_card[:street_address] = address[:address1] if address[:address1]
credit_card[:street_address] += ' ' + address[:address2] if address[:address2]
credit_card[:postal_code] = address[:zip] if address[:zip]
credit_card[:country] = address[:country] if address[:country]
end
end
def add_credit_card(post, credit_card, options)
if credit_card.respond_to? :number
card = {}
card[:card_number] = credit_card.number
card[:expiration_month] = credit_card.month
card[:expiration_year] = credit_card.year
card[:security_code] = credit_card.verification_value if credit_card.verification_value?
card[:name] = credit_card.name if credit_card.name
add_address(card, options)
response = http_request(:post, @cards_uri, card)
if error?(response)
raise CardDeclined, response
end
card_uri = response['uri']
associate_card_to_account(post[:account_uri], card_uri)
post[:card_uri] = card_uri
elsif credit_card.kind_of?(String)
post[:card_uri] = credit_card
end
post[:card_uri]
end
def associate_card_to_account(account_uri, card_uri)
http_request(:put, account_uri, :card_uri => card_uri)
end
def http_request(method, url, parameters={}, meta={})
begin
if method == :get
raw_response = ssl_get(LIVE_URL + url, headers(meta))
else
raw_response = ssl_request(method,
LIVE_URL + url,
post_data(parameters),
headers(meta))
end
parse(raw_response)
rescue ResponseError => e
raw_response = e.response.body
response_error(raw_response)
rescue JSON::ParserError
json_error(raw_response)
end
end
def create_transaction(method, url, parameters, meta={})
response = http_request(method, url, parameters, meta)
success = !error?(response)
Response.new(success,
(success ? "Transaction approved" : response["description"]),
response,
:test => (@marketplace_uri.index("TEST") ? true : false),
:authorization => response["uri"]
)
end
def failed_response(response)
is_test = false
if @marketplace_uri
is_test = (@marketplace_uri.index("TEST") ? true : false)
end
Response.new(false,
response["description"],
response,
:test => is_test
)
end
def parse(body)
JSON.parse(body)
end
def response_error(raw_response)
begin
parse(raw_response)
rescue JSON::ParserError
json_error(raw_response)
end
end
def json_error(raw_response)
msg = 'Invalid response received from the Balanced API. Please contact support@balancedpayments.com if you continue to receive this message.'
msg += " (The raw response returned by the API was #{raw_response.inspect})"
{
"error" => {
"message" => msg
}
}
end
def error?(response)
response.key?('status_code')
end
def post_data(params)
return nil unless params
params.map do |key, value|
next if value.blank?
if value.is_a?(Hash)
h = {}
value.each do |k, v|
h["#{key}[#{k}]"] = v unless v.blank?
end
post_data(h)
else
"#{key}=#{CGI.escape(value.to_s)}"
end
end.compact.join("&")
end
def headers(meta={})
@@ua ||= JSON.dump({
:bindings_version => ActiveMerchant::VERSION,
:lang => 'ruby',
:lang_version => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})",
:lib_version => BalancedGateway::VERSION,
:platform => RUBY_PLATFORM,
:publisher => 'active_merchant'
})
{
"Authorization" => "Basic " + Base64.encode64(@options[:login].to_s + ":").strip,
"User-Agent" => "Balanced/v1 ActiveMerchantBindings/#{ActiveMerchant::VERSION}",
"X-Balanced-User-Agent" => @@ua,
}
end
end
end
end