Go back to the main page

Migrating a QuickBooks Online App from OAuth1 to OAuth2 using the qbo_api gem - Step 1

 

OAuth2 Renewal Strategy

The QuickBooks Online API OAuth2 access token's are only good for 1 hour. This is a radical departure from OAuth1's 6 month expiry duration. With that short a window probably the best way to ensure uninterrupted usage is to utilize a code pattern for all QBO request that does the following:

  1. Catches "Unauthorized" errors (status code 401 — i.e. an invalid access token).
  2. Next, automatically renews and persists the access token.
  3. Lastly, replays the failed 401 request.

In the screencast, I'll be focusing on this approach. See the show notes below for an example code pattern that will make OAuth2 renewal and persistence automatic. Note: The code pattern below requires customization for your particular situation.

Scheduled Renewal Job?

With OAuth1's one month renewal window you could rely on a background scheduler like Cron but with the OAuth2's super short renewal duration, relying solely on Cron may not be best because in the case that your app or the QBO API has an outage when either service returns it may take considerably longer to get QBO requests flowing again. I talk more about going with a background scheduler strategy here.

Show Notes:

  1. Update the qbo_api gem to the latest.
  2. Refactor your QuickBooks Online API requests in to the "request_handler" pattern as seen below. The qbo_account, record, and payload ideas are specific to my app that I'm switching over to OAuth2. Please watch the screencast to get the proper context.

module Qbo
    class Base

        def self.request_handler(qbo_account_id:, payload: false, record: false)
            retries ||= 0
            qbo_account = QboAccount.find(qbo_account_id)
            api = Qbo.init(qbo_account)
            yield(api)
        rescue QboApi::Unauthorized
            if qbo_account.access_token.present?
                Qbo::OAuth2.renew(qbo_account)
                retry if (retries += 1) < 3
            else
                register_error(qbo_account: qbo_account, error: '401 Authentication Error', payload: payload)
            end
        rescue QboApi::Error => e
            if err = e.fault
                if record
                    fix_duplicate_customer(qbo_account: qbo_account, record: record, error: err, payload: payload, qbo_api: api)
                else
                    final_err = error_body(err)
                    register_error(qbo_account: qbo_account, error: final_err, payload: payload)
                end
            end
        rescue => e
            final_err = e.to_s[0..255]
            qbo_account.account.qbo_errors.create(body: final_err, request_json: payload.to_json, account: qbo_account.account)
            raise e
        end

        def self.entity_handler(qbo_account_id:, entity:, record:, payload:)
            request_handler(qbo_account_id: qbo_account_id, record: record, payload: payload) do |api|
                if id = record.qbo_id
                    res = api.update(entity, id: id, payload: payload)
                else
                    res = api.create(entity, payload: payload)
                    record.update(qbo_id: res['Id'])
                end
                res
            end
        end

        def self.fix_duplicate_customer(qbo_account:, record:, error:, payload:, qbo_api: api)
            if record.class.name == "Customer" && error[:error_body][0][:error_message] == 'Duplicate Name Exists Error'
                if res = qbo_api.get(:customer, ["DisplayName", record.display_name])
                    record.update(qbo_id: res['Id'])
                else
                    register_error(qbo_account: qbo_account, error: error, payload: payload, resource: record)
                end
            else
                register_error(qbo_account: qbo_account, error: error, payload: payload, resource: record)
            end
        end

        def self.register_error(qbo_account:, error:, payload:, resource: false)
            qbo_account.account.qbo_errors.create(body: error[:error_body], method: error[:method], resource: resource,
                                                                                        url: error[:url], status: error[:status], request_json: payload.to_json) 
        end

        def self.error_body(err)
            if body = err.try(:[], :error_body)
                result = body.map{ |b| b.try(:[], :error_detail) || b.try(:[], :error_message) }.join(" -- ")
            else
                ""
            end
        end
    end
end
  
module Qbo

  def self.init(qbo_account)
    if qbo_account.access_token.present?
      attrs = {
        access_token: qbo_account.access_token,
        realm_id: qbo_account.companyid
      }
    else
      attrs = { 
        token: qbo_account.token,
        token_secret: qbo_account.secret,
        realm_id: qbo_account.companyid,
        consumer_key: Rails.application.secrets.qbo_app_consumer_key,
        consumer_secret: Rails.application.secrets.qbo_app_consumer_secret
      }
    end
    QboApi.new(attrs)
  end

end