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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/example/app.log +189 -0
- data/example/app.rb +51 -0
- data/example/views/customer.erb +10 -0
- data/example/views/index.erb +25 -0
- data/lib/qbo_api.rb +158 -0
- data/lib/qbo_api/configuration.rb +28 -0
- data/lib/qbo_api/entity.rb +86 -0
- data/lib/qbo_api/error.rb +41 -0
- data/lib/qbo_api/raise_http_exception.rb +80 -0
- data/lib/qbo_api/version.rb +3 -0
- data/qbo_api.gemspec +35 -0
- metadata +260 -0
data/example/app.rb
ADDED
@@ -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,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>
|
data/lib/qbo_api.rb
ADDED
@@ -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
|