Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 126 additions & 11 deletions quickbooks/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ class QuickBooks(object):
invoice_link = False
use_decimal = False

# The refresh token currently issued for this connection. Intuit rotates
# this value roughly every 24 hours and the previous value stops working
# the moment it does, so it is kept up to date after every token response
# rather than treated as static configuration.
refresh_token = None
access_token = None

# Called with the token payload after every token response so the caller
# can persist the current refresh token. See README, "Storing tokens".
refresh_token_callback = None

# Refresh the access token and retry once when the API answers 401.
auto_refresh = True

sandbox_api_url_v3 = "https://sandbox-quickbooks.api.intuit.com/v3"
api_url_v3 = "https://quickbooks.api.intuit.com/v3"
current_user_url = "https://appcenter.intuit.com/api/v1/user/current"
Expand Down Expand Up @@ -62,6 +76,12 @@ def __new__(cls, **kwargs):
if 'refresh_token' in kwargs:
instance.refresh_token = kwargs['refresh_token']

if 'refresh_token_callback' in kwargs:
instance.refresh_token_callback = kwargs['refresh_token_callback']

if 'auto_refresh' in kwargs:
instance.auto_refresh = kwargs['auto_refresh']

if 'auth_client' in kwargs:
instance.auth_client = kwargs['auth_client']

Expand All @@ -70,8 +90,7 @@ def __new__(cls, **kwargs):
else:
instance.sandbox = False

refresh_token = instance._start_session()
instance.refresh_token = refresh_token
instance._start_session()

if 'company_id' in kwargs:
instance.company_id = kwargs['company_id']
Expand Down Expand Up @@ -101,18 +120,101 @@ def __new__(cls, **kwargs):
return instance

def _start_session(self):
"""Prepare the HTTP session, getting an access token if there isn't one.

Returns the refresh token this client will use from here on: the most
recent one the token service issued, or the caller's if no token call
was needed. Never None, and never an older value than either.
"""
if self.auth_client.access_token is None:
self.auth_client.refresh(refresh_token=self.refresh_token)
self.refresh_access_token()
else:
# An access token was supplied, so no token call is needed. Read
# whatever the auth client holds without discarding what we were
# given: it may have no refresh token at all.
self._store_tokens(notify=False)

self.session = OAuth2Session(self.auth_client.client_id, token=self._session_token())

return self.refresh_token

self.session = OAuth2Session(
self.auth_client.client_id,
token={
'access_token': self.auth_client.access_token,
'refresh_token': self.auth_client.refresh_token,
}
)
def refresh_access_token(self):
"""Get a new access token, keeping the refresh token current.

return self.auth_client.refresh_token
Intuit hands back a refresh token with every token response. Most of the
time it is the value that was sent; roughly 24 hours after a value is
issued the service rotates it, and from that moment the previous value
is rejected with "Incorrect or invalid refresh token". The replacement
appears in that one response and nowhere else, so it is captured here
and passed to ``refresh_token_callback`` for the caller to persist.
"""
presented = self.refresh_token or getattr(self.auth_client, 'refresh_token', None)

if not presented:
raise exceptions.QuickbooksException(
'No refresh token available. Pass refresh_token to QuickBooks() '
'(or to AuthClient()) with the value stored for this company.',
10000)

self.auth_client.refresh(refresh_token=presented)
self._store_tokens(presented=presented)

if self.session is not None:
self.session.token = self._session_token()

return self.refresh_token

def _store_tokens(self, presented=None, notify=True):
"""Take the tokens off the auth client and tell the caller about them."""
issued = getattr(self.auth_client, 'refresh_token', None)

# Only ever move forward: a token response always carries a refresh
# token, but an auth client that has not made one yet carries None.
if issued:
self.refresh_token = issued

if self.auth_client.access_token:
self.access_token = self.auth_client.access_token

if notify and self.refresh_token_callback is not None:
self.refresh_token_callback({
'refresh_token': self.refresh_token,
'access_token': self.access_token,
'expires_in': getattr(self.auth_client, 'expires_in', None),
'x_refresh_token_expires_in': getattr(
self.auth_client, 'x_refresh_token_expires_in', None),
'realm_id': getattr(self.auth_client, 'realm_id', None),
'rotated': bool(presented and issued and issued != presented),
})

def _session_token(self):
return {
'access_token': self.access_token,
'refresh_token': self.refresh_token,
}

def _refresh_and_retry(self, request_type, url, headers, params, data):
"""Answer to a 401: get a new access token and send the request again.

Returns None when refreshing is not possible or did not help.
"""
if self.auth_client is None or not self.auto_refresh:
return None

try:
self.refresh_access_token()
except exceptions.QuickbooksException:
return None
except Exception as error:
# The refresh token is gone as far as the service is concerned --
# the connection has to be authorized again.
raise exceptions.AuthorizationException(
'Failed to refresh the access token. The connection must be '
'authorized again.', error_code=httplib.UNAUTHORIZED,
detail=str(error))

return self.process_request(
request_type, url, headers=headers, params=params, data=data)

def _drop(self):
QuickBooks.__instance = None
Expand Down Expand Up @@ -219,6 +321,14 @@ def make_request(self, request_type, url, request_body=None, content_type='appli

req = self.process_request(request_type, url, headers=headers, params=params, data=request_body)

if req.status_code == httplib.UNAUTHORIZED:
# Access tokens last an hour. Get a fresh one and send the request
# once more before giving up on the connection.
retried = self._refresh_and_retry(
request_type, url, headers, params, request_body)
if retried is not None:
req = retried

if req.status_code == httplib.UNAUTHORIZED:
raise exceptions.AuthorizationException(
"Application authentication failed", error_code=req.status_code, detail=req.text)
Expand Down Expand Up @@ -363,6 +473,11 @@ def download_pdf(self, qbbo, item_id):

response = self.process_request("GET", url, headers=headers)

if response.status_code == httplib.UNAUTHORIZED:
retried = self._refresh_and_retry("GET", url, headers, "", "")
if retried is not None:
response = retried

if response.status_code != httplib.OK:

if response.status_code == httplib.UNAUTHORIZED:
Expand Down
Loading