qbo_api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ require "bundler/setup"
2
+ require 'sinatra'
3
+ require 'json'
4
+ require 'omniauth'
5
+ require 'omniauth-quickbooks'
6
+ require 'dotenv'
7
+ require 'qbo_api'
8
+ Dotenv.load '../.env'
9
+
10
+ PORT = 9393
11
+ CONSUMER_KEY = ENV['QBO_API_CONSUMER_KEY']
12
+ CONSUMER_SECRET = ENV['QBO_API_CONSUMER_SECRET']
13
+
14
+ set :port, PORT
15
+ use Rack::Session::Cookie
16
+ use OmniAuth::Builder do
17
+ provider :quickbooks, CONSUMER_KEY, CONSUMER_SECRET
18
+ end
19
+
20
+ get '/' do
21
+ @app_center = QboApi::APP_CENTER_BASE
22
+ @auth_data = oauth_data
23
+ @port = PORT
24
+ erb :index
25
+ end
26
+
27
+ get '/customer/:id' do
28
+ if session[:token]
29
+ api = QboApi.new(oauth_data)
30
+ @resp = api.get :customer, params[:id]
31
+ end
32
+ erb :customer
33
+ end
34
+
35
+ def oauth_data
36
+ {
37
+ consumer_key: CONSUMER_KEY,
38
+ consumer_secret: CONSUMER_SECRET,
39
+ token: session[:token],
40
+ token_secret: session[:secret],
41
+ realm_id: session[:realm_id]
42
+ }
43
+ end
44
+
45
+ get '/auth/quickbooks/callback' do
46
+ auth = env["omniauth.auth"][:credentials]
47
+ session[:token] = auth[:token]
48
+ session[:secret] = auth[:secret]
49
+ session[:realm_id] = params['realmId']
50
+ '<!DOCTYPE html><html lang="en"><head></head><body><script>window.opener.location.reload(); window.close();</script></body></html>'
51
+ end
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title></title>
6
+ </head>
7
+ <body>
8
+ <h1><%= @resp['Customer']['DisplayName'] %></h1>
9
+ </body>
10
+ </html>
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>QBO Connect</title>
6
+
7
+ <script type="text/javascript" src="<%= @app_center %>/Content/IA/intuit.ipp.anywhere-1.3.1.js"></script>
8
+ <script>
9
+ intuit.ipp.anywhere.setup({
10
+ grantUrl: "http://localhost:<%= @port %>/auth/quickbooks",
11
+ datasources: {
12
+ quickbooks : true,
13
+ payments : true
14
+ }
15
+ });
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <% if session[:token] %>
20
+ <p><%= @auth_data %></p>
21
+ <% end %>
22
+
23
+ <ipp:connectToIntuit></ipp:connectToIntuit>
24
+ </body>
25
+ </html>
@@ -0,0 +1,158 @@
1
+ require 'qbo_api/version'
2
+ require 'json'
3
+ #require 'rack'
4
+ require 'logger'
5
+ require 'faraday'
6
+ require 'faraday_middleware'
7
+ require 'faraday/detailed_logger'
8
+ require_relative 'qbo_api/configuration'
9
+ require_relative 'qbo_api/error'
10
+ require_relative 'qbo_api/raise_http_exception'
11
+ require_relative 'qbo_api/entity'
12
+
13
+ class QboApi
14
+ extend Configuration
15
+ include Entity
16
+ attr_reader :realm_id
17
+
18
+ REQUEST_TOKEN_URL = 'https://oauth.intuit.com/oauth/v1/get_request_token'
19
+ ACCESS_TOKEN_URL = 'https://oauth.intuit.com/oauth/v1/get_access_token'
20
+ APP_CENTER_BASE = 'https://appcenter.intuit.com'
21
+ APP_CENTER_URL = APP_CENTER_BASE + '/Connect/Begin?oauth_token='
22
+ V3_ENDPOINT_BASE_URL = 'https://sandbox-quickbooks.api.intuit.com/v3/company/'
23
+ PAYMENTS_API_BASE_URL = 'https://sandbox.api.intuit.com/quickbooks/v4/payments'
24
+ APP_CONNECTION_URL = APP_CENTER_BASE + '/api/v1/connection'
25
+
26
+ def initialize(token:, token_secret:, realm_id:, consumer_key: CONSUMER_KEY,
27
+ consumer_secret: CONSUMER_SECRET, endpoint: :accounting)
28
+ @consumer_key = consumer_key
29
+ @consumer_secret = consumer_secret
30
+ @token = token
31
+ @token_secret = token_secret
32
+ @realm_id = realm_id
33
+ @endpoint = endpoint
34
+ end
35
+
36
+ def connection(url: get_endpoint)
37
+ Faraday.new(url: url) do |faraday|
38
+ faraday.headers['Content-Type'] = 'application/json;charset=UTF-8'
39
+ faraday.headers['Accept'] = "application/json"
40
+ faraday.request :oauth, oauth_data
41
+ faraday.request :url_encoded
42
+ faraday.use FaradayMiddleware::RaiseHttpException
43
+ faraday.response :detailed_logger, QboApi.logger if QboApi.log
44
+ faraday.adapter Faraday.default_adapter
45
+ end
46
+ end
47
+
48
+ def query(query)
49
+ path = "#{realm_id}/query?query=#{query}"
50
+ entity = extract_entity_from_query(query, to_sym: true)
51
+ request(:get, entity: entity, path: path)
52
+ end
53
+
54
+ def get(entity, id)
55
+ path = "#{realm_id}/#{entity.to_s}/#{id}"
56
+ request(:get, entity: entity, path: path)
57
+ end
58
+
59
+ def create(entity, payload:)
60
+ path = "#{realm_id}/#{entity}"
61
+ request(:post, entity: entity, path: path, payload: payload)
62
+ end
63
+
64
+ def update(entity, id:, payload:)
65
+ path = "#{realm_id}/#{entity}"
66
+ payload.merge!(set_update(entity, id))
67
+ request(:post, entity: entity, path: path, payload: payload)
68
+ end
69
+
70
+ def delete(entity, id:)
71
+ raise QboApi::NotImplementedError unless is_transaction_entity?(entity)
72
+ path = "#{realm_id}/#{entity}?operation=delete"
73
+ payload = set_update(entity, id)
74
+ request(:post, entity: entity, path: path, payload: payload)
75
+ end
76
+
77
+ # TODO: Need specs for disconnect and reconnect
78
+ # https://developer.intuit.com/docs/0100_accounting/0060_authentication_and_authorization/oauth_management_api
79
+ def disconnect
80
+ response = connection(url: APP_CONNECTION_URL).get('/disconnect')
81
+ end
82
+
83
+ def reconnect
84
+ response = connection(url: APP_CONNECTION_URL).get('/reconnect')
85
+ end
86
+
87
+ def all(entity, max: 1000, select: nil, &block)
88
+ select ||= "SELECT * FROM #{singular(entity)}"
89
+ pos = 0
90
+ begin
91
+ pos = pos == 0 ? pos + 1 : pos + max
92
+ results = query("#{select} MAXRESULTS #{max} STARTPOSITION #{pos}")
93
+ results.each do |entry|
94
+ yield(entry)
95
+ end if results
96
+ end while (results ? results.size == max : false)
97
+ end
98
+
99
+ def request(method, entity:, path:, payload: nil)
100
+ raw_response = connection.send(method) do |req|
101
+ case method
102
+ when :get, :delete
103
+ req.url URI.encode(path)
104
+ when :post, :put
105
+ req.url path
106
+ req.body = JSON.generate(payload)
107
+ end
108
+ end
109
+ response(raw_response, entity: entity)
110
+ end
111
+
112
+ def response(resp, entity: nil)
113
+ j = JSON.parse(resp.body)
114
+ if entity
115
+ if qr = j['QueryResponse']
116
+ qr.empty? ? nil : qr.fetch(singular(entity))
117
+ else
118
+ j.fetch(singular(entity))
119
+ end
120
+ else
121
+ j
122
+ end
123
+ rescue => e
124
+ # Catch fetch key errors and just return JSON
125
+ j
126
+ end
127
+
128
+ def esc(query)
129
+ query.gsub("'", "\\\\'")
130
+ end
131
+
132
+ private
133
+
134
+ def oauth_data
135
+ {
136
+ consumer_key: @consumer_key,
137
+ consumer_secret: @consumer_secret,
138
+ token: @token,
139
+ token_secret: @token_secret
140
+ }
141
+ end
142
+
143
+ def set_update(entity, id)
144
+ resp = get(entity, id)
145
+ { Id: resp['Id'], SyncToken: resp['SyncToken'] }
146
+ end
147
+
148
+ def get_endpoint
149
+ prod = self.class.production
150
+ case @endpoint
151
+ when :accounting
152
+ prod ? V3_ENDPOINT_BASE_URL.sub("sandbox-", '') : V3_ENDPOINT_BASE_URL
153
+ when :payments
154
+ prod ? PAYMENTS_API_BASE_URL.sub("sandbox.", '') : PAYMENTS_API_BASE_URL
155
+ end
156
+ end
157
+
158
+ end
@@ -0,0 +1,28 @@
1
+ class QboApi
2
+ module Configuration
3
+
4
+ def logger
5
+ @logger ||= ::Logger.new($stdout)
6
+ end
7
+
8
+ def logger=(logger)
9
+ @logger = logger
10
+ end
11
+
12
+ def log
13
+ @log ||= false
14
+ end
15
+
16
+ def log=(value)
17
+ @log = value
18
+ end
19
+
20
+ def production
21
+ @production ||= false
22
+ end
23
+
24
+ def production=(value)
25
+ @production = value
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,86 @@
1
+ class QboApi
2
+ module Entity
3
+
4
+ def singular(entity)
5
+ e = snake_to_camel(entity)
6
+ case e
7
+ when 'Classes'
8
+ 'Class'
9
+ when /^(Entitlements|Preferences)$/
10
+ e
11
+ else
12
+ e.chomp('s')
13
+ end
14
+ end
15
+
16
+ def snake_to_camel(sym)
17
+ sym.to_s.split('_').collect(&:capitalize).join
18
+ end
19
+
20
+ def is_transaction_entity?(entity)
21
+ transaction_entities.include?(singular(entity))
22
+ end
23
+
24
+ def transaction_entities
25
+ %w{
26
+ Bill
27
+ BillPayment
28
+ CreditMemo
29
+ Deposit
30
+ Estimate
31
+ Invoice
32
+ JournalEntry
33
+ Payment
34
+ Purchase
35
+ PurchaseOrder
36
+ RefundReceipt
37
+ SalesReceipt
38
+ TimeActivity
39
+ Transfer
40
+ VendorCredit
41
+ }
42
+ end
43
+
44
+ def is_name_list_entity?(entity)
45
+ name_list_entities.include?(singular(entity))
46
+ end
47
+
48
+ def name_list_entities
49
+ %w{
50
+ Account
51
+ Budget
52
+ Class
53
+ CompanyCurrency
54
+ Customer
55
+ Department
56
+ Employee
57
+ Item
58
+ JournalCode
59
+ PaymentMethod
60
+ TaxAgency
61
+ TaxCode
62
+ TaxRate
63
+ TaxService
64
+ Term
65
+ Vendor
66
+ }
67
+ end
68
+
69
+ def supporting_entities
70
+ %w{
71
+ Attachable
72
+ CompanyInfo
73
+ Entitlements
74
+ ExchangeRate
75
+ Preferences
76
+ }
77
+ end
78
+
79
+ def extract_entity_from_query(query, to_sym: false)
80
+ if m = query.match(/from\s+(\w+)\s/i)
81
+ (to_sym ? m[1].downcase.to_sym : m[1].capitalize) if m[1]
82
+ end
83
+ end
84
+
85
+ end
86
+ end
@@ -0,0 +1,41 @@
1
+
2
+ #200 OK The request succeeded. However, the response body may contain a <Fault> element, indicating an error.
3
+ #400 Bad request Generally, the request cannot be fulfilled due to bad syntax. In some cases, this response code is returned for a request with bad authorization data.
4
+ #401 Unauthorized Authentication or authorization has failed.
5
+ #403 Forbidden The resource is forbidden.
6
+ #404 Not Found The resource is not found.
7
+ #500 Internal Server Error An error occured on the server while processing the request. Resubmit request once; if it persists, contact developer support.
8
+ #503 Service Unavailable The service is temporarily unavailable.
9
+ # Custom error class for rescuing from all QuickBooks Online errors
10
+ class QboApi
11
+ class Error < StandardError
12
+ attr_reader :fault
13
+ def initialize(errors = nil)
14
+ if errors
15
+ @fault = errors
16
+ super(errors[:error_body])
17
+ end
18
+ end
19
+ end
20
+
21
+ # Raised when trying an action that is not supported
22
+ class NotImplementedError < Error; end
23
+
24
+ # Raised when QuickBooks Online returns the HTTP status code 400
25
+ class BadRequest < Error; end
26
+
27
+ # Raised when QuickBooks Online returns the HTTP status code 401
28
+ class Unauthorized < Error; end
29
+
30
+ # Raised when QuickBooks Online returns the HTTP status code 403
31
+ class Forbidden < Error; end
32
+
33
+ # Raised when QuickBooks Online returns the HTTP status code 404
34
+ class NotFound < Error; end
35
+
36
+ # Raised when QuickBooks Online returns the HTTP status code 500
37
+ class InternalServerError < Error; end
38
+
39
+ # Raised when QuickBooks Online returns the HTTP status code 503
40
+ class ServiceUnavailable < Error; end
41
+ end
@@ -0,0 +1,80 @@
1
+ require 'faraday'
2
+ require 'nokogiri'
3
+
4
+ # @private
5
+ module FaradayMiddleware
6
+ # @private
7
+ class RaiseHttpException < Faraday::Middleware
8
+ def call(env)
9
+ @app.call(env).on_complete do |response|
10
+ case response.status
11
+ when 200
12
+ # 200 responses can have errors
13
+ raise QboApi::BadRequest.new(error_message(response)) if response.body =~ /Fault.*Error.*Message/
14
+ when 400
15
+ raise QboApi::BadRequest.new(error_message(response))
16
+ when 401
17
+ raise QboApi::Unauthorized.new(error_message(response))
18
+ when 403
19
+ raise QboApi::Forbidden.new(error_message(response))
20
+ when 404
21
+ raise QboApi::NotFound.new(error_message(response))
22
+ when 500
23
+ raise QboApi::InternalServerError.new(error_message(response))
24
+ when 503
25
+ raise QboApi::ServiceUnavailable.new(error_message(response))
26
+ end
27
+ end
28
+ end
29
+
30
+ def initialize(app)
31
+ super app
32
+ end
33
+
34
+ private
35
+
36
+ def error_message(response)
37
+ {
38
+ method: response.method,
39
+ url: response.url,
40
+ status: response.status,
41
+ error_body: error_body(response.body)
42
+ }
43
+ end
44
+
45
+ def error_body(body)
46
+ if not body.nil? and not body.empty? and body.kind_of?(String)
47
+ body =~ /IntuitResponse/ ? parse_xml(body) : parse_json(body)
48
+ else
49
+ nil
50
+ end
51
+ end
52
+
53
+ def parse_json(body)
54
+ res = ::JSON.parse(body)
55
+ r = res['Fault']['Error']
56
+ r.collect do |e|
57
+ {
58
+ fault_type: e['type'],
59
+ error_code: e['code'],
60
+ error_message: e['Message'],
61
+ error_detail: e['Detail']
62
+ }
63
+ end
64
+ end
65
+
66
+ def parse_xml(body)
67
+ res = ::Nokogiri::XML(body)
68
+ r = res.css('Error')
69
+ r.collect do |e|
70
+ {
71
+ fault_type: res.at('Fault')['type'],
72
+ error_code: res.at('Error')['code'],
73
+ error_message: e.at('Message').content,
74
+ error_detail: e.at('Detail').content
75
+ }
76
+ end
77
+ end
78
+
79
+ end
80
+ end