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