lib/active_merchant/billing/gateways/quickbooks.rb in activemerchant-1.100.0 vs lib/active_merchant/billing/gateways/quickbooks.rb in activemerchant-1.101.0

- old
+ new

@@ -8,17 +8,14 @@ self.default_currency = 'USD' self.supported_cardtypes = [:visa, :master, :american_express, :discover, :diners] self.homepage_url = 'http://payments.intuit.com' self.display_name = 'QuickBooks Payments' - ENDPOINT = '/quickbooks/v4/payments/charges' - OAUTH_ENDPOINTS = { - site: 'https://oauth.intuit.com', - request_token_path: '/oauth/v1/get_request_token', - authorize_url: 'https://appcenter.intuit.com/Connect/Begin', - access_token_path: '/oauth/v1/get_access_token' - } + BASE = '/quickbooks/v4/payments' + ENDPOINT = "#{BASE}/charges" + VOID_ENDPOINT = "#{BASE}/txn-requests" + REFRESH_URI = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer' # https://developer.intuit.com/docs/0150_payments/0300_developer_guides/error_handling STANDARD_ERROR_CODE_MAPPING = { # Fraud Warnings @@ -49,50 +46,70 @@ } FRAUD_WARNING_CODES = ['PMT-1000', 'PMT-1001', 'PMT-1002', 'PMT-1003'] def initialize(options = {}) - requires!(options, :consumer_key, :consumer_secret, :access_token, :token_secret, :realm) + # Quickbooks is deprecating OAuth 1.0 on December 17, 2019. + # OAuth 2.0 requires a client_id, client_secret, access_token, and refresh_token + # To maintain backwards compatibility, check for the presence of a refresh_token (only specified for OAuth 2.0) + # When present, validate that all OAuth 2.0 options are present + if options[:refresh_token] + requires!(options, :client_id, :client_secret, :access_token, :refresh_token) + else + requires!(options, :consumer_key, :consumer_secret, :access_token, :token_secret, :realm) + end @options = options super end def purchase(money, payment, options = {}) post = {} add_amount(post, money, options) add_charge_data(post, payment, options) post[:capture] = 'true' - commit(ENDPOINT, post) + response = commit(ENDPOINT, post) + check_token_response(response, ENDPOINT, post) end def authorize(money, payment, options = {}) post = {} add_amount(post, money, options) add_charge_data(post, payment, options) post[:capture] = 'false' - commit(ENDPOINT, post) + response = commit(ENDPOINT, post) + check_token_response(response, ENDPOINT, post) end def capture(money, authorization, options = {}) post = {} - capture_uri = "#{ENDPOINT}/#{CGI.escape(authorization)}/capture" + authorization, _ = split_authorization(authorization) post[:amount] = localized_amount(money, currency(money)) add_context(post, options) - commit(capture_uri, post) + response = commit(capture_uri(authorization), post) + check_token_response(response, capture_uri(authorization), post) end def refund(money, authorization, options = {}) post = {} post[:amount] = localized_amount(money, currency(money)) add_context(post, options) + authorization, _ = split_authorization(authorization) - commit(refund_uri(authorization), post) + response = commit(refund_uri(authorization), post) + check_token_response(response, refund_uri(authorization), post) end + def void(authorization, options = {}) + _, request_id = split_authorization(authorization) + + response = commit(void_uri(request_id)) + check_token_response(response, void_uri(request_id)) + end + def verify(credit_card, options = {}) authorize(1.00, credit_card, options) end def supports_scrubbing? @@ -105,11 +122,16 @@ gsub(%r((oauth_consumer_key=\")\w+), '\1[FILTERED]'). gsub(%r((oauth_nonce=\")\w+), '\1[FILTERED]'). gsub(%r((oauth_signature=\")[a-zA-Z%0-9]+), '\1[FILTERED]'). gsub(%r((oauth_token=\")\w+), '\1[FILTERED]'). gsub(%r((number\D+)\d{16}), '\1[FILTERED]'). - gsub(%r((cvc\D+)\d{3}), '\1[FILTERED]') + gsub(%r((cvc\D+)\d{3}), '\1[FILTERED]'). + gsub(%r((Authorization: Basic )\w+), '\1[FILTERED]'). + gsub(%r((access_token\\?":\\?")[\w\-\.]+)i, '\1[FILTERED]'). + gsub(%r((refresh_token\\?":\\?")\w+), '\1[FILTERED]'). + gsub(%r((refresh_token=)\w+), '\1[FILTERED]'). + gsub(%r((Authorization: Bearer )[\w\-\.]+)i, '\1[FILTERED]\2') end private def add_charge_data(post, payment, options = {}) @@ -168,34 +190,34 @@ def commit(uri, body = {}, method = :post) endpoint = gateway_url + uri # The QuickBooks API returns HTTP 4xx on failed transactions, which causes a # ResponseError raise, so we have to inspect the response and discern between # a legitimate HTTP error and an actual gateway transactional error. - response = begin - case method - when :post - ssl_post(endpoint, post_data(body), headers(:post, endpoint)) - when :get - ssl_request(:get, endpoint, nil, headers(:get, endpoint)) - else - raise ArgumentError, "Invalid HTTP method: #{method}. Valid methods are :post and :get" + headers = {} + response = + begin + headers = headers(method, endpoint) + method == :post ? ssl_post(endpoint, post_data(body), headers) : ssl_request(:get, endpoint, nil, headers) + rescue ResponseError => e + extract_response_body_or_raise(e) end - rescue ResponseError => e - extract_response_body_or_raise(e) - end - response_object(response) + response_object(response, headers) end - def response_object(raw_response) + def response_object(raw_response, headers = {}) parsed_response = parse(raw_response) + # Include access_token and refresh_token in params for OAuth 2.0 + parsed_response['access_token'] = @options[:access_token] if @options[:refresh_token] + parsed_response['refresh_token'] = @options[:refresh_token] if @options[:refresh_token] + Response.new( success?(parsed_response), message_from(parsed_response), parsed_response, - authorization: authorization_from(parsed_response), + authorization: authorization_from(parsed_response, headers), test: test?, cvv_result: cvv_code_from(parsed_response), error_code: errors_from(parsed_response), fraud_review: fraud_review_status_from(parsed_response) ) @@ -208,10 +230,12 @@ def post_data(data = {}) data.to_json end def headers(method, uri) + return oauth_v2_headers if @options[:refresh_token] + raise ArgumentError, "Invalid HTTP method: #{method}. Valid methods are :post and :get" unless [:post, :get].include?(method) request_uri = URI.parse(uri) # Following the guidelines from http://nouncer.com/oauth/authentication.html oauth_parameters = { @@ -241,10 +265,46 @@ 'Request-Id' => generate_unique_id, 'Authorization' => oauth_headers.join(', ') } end + def oauth_v2_headers + { + 'Content-Type' => 'application/json', + 'Request-Id' => generate_unique_id, + 'Accept' => 'application/json', + 'Authorization' => "Bearer #{@options[:access_token]}" + } + end + + def check_token_response(response, endpoint, body = {}) + return response unless @options[:refresh_token] + return response unless response.params['code'] == 'AuthenticationFailed' + refresh_access_token + commit(endpoint, body) + end + + def refresh_access_token + post = {} + post[:grant_type] = 'refresh_token' + post[:refresh_token] = @options[:refresh_token] + data = post.collect { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join('&') + + basic_auth = Base64.strict_encode64("#{@options[:client_id]}:#{@options[:client_secret]}") + headers = { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + 'Authorization' => "Basic #{basic_auth}" + } + + response = ssl_post(REFRESH_URI, data, headers) + response = JSON.parse(response) + + @options[:access_token] = response['access_token'] if response['access_token'] + @options[:refresh_token] = response['refresh_token'] if response['refresh_token'] + end + def cvv_code_from(response) if response['errors'].present? FRAUD_WARNING_CODES.include?(response['errors'].first['code']) ? 'I' : '' else success?(response) ? 'M' : '' @@ -263,14 +323,19 @@ def errors_from(response) response['errors'].present? ? STANDARD_ERROR_CODE_MAPPING[response['errors'].first['code']] : '' end - def authorization_from(response) - response['id'] + def authorization_from(response, headers = {}) + [response['id'], headers['Request-Id']].join('|') end + def split_authorization(authorization) + authorization, request_id = authorization.split('|') + [authorization, request_id] + end + def fraud_review_status_from(response) response['errors'] && FRAUD_WARNING_CODES.include?(response['errors'].first['code']) end def extract_response_body_or_raise(response_error) @@ -281,10 +346,18 @@ end response_error.response.body end def refund_uri(authorization) - "#{ENDPOINT}/#{CGI.escape(authorization)}/refunds" + "#{ENDPOINT}/#{CGI.escape(authorization.to_s)}/refunds" + end + + def capture_uri(authorization) + "#{ENDPOINT}/#{CGI.escape(authorization.to_s)}/capture" + end + + def void_uri(request_id) + "#{VOID_ENDPOINT}/#{CGI.escape(request_id.to_s)}/void" end end end end