qbo_api 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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