-
Notifications
You must be signed in to change notification settings - Fork 72
feat: Add QuickBooks verified source #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 12 commits
b806f69
e2f689d
7ea5b24
560f69c
0f34463
38c626d
130b4d5
8e6b506
1198241
2beb6ef
e7a7150
744b20d
79de24f
5a8fbea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# Quickbooks | ||
|
||
QuickBooks is a cloud-based accounting software designed for small to medium-sized businesses. This QuickBooks `dlt` verified source and pipeline example offers the capability to load QuickBooks endpoints such as "Customer" to a destination of your choosing. It enables you to conveniently load the following endpoint as a start: | ||
|
||
### Single loading endpoints (replace mode) | ||
|
||
| Endpoint | Mode | Description | | ||
| --- | --- | --- | | ||
| Customer | replace | A customer is a consumer of the service or product that your business offers. An individual customer can have an underlying nested structure, with a parent customer (the top-level object) having zero or more sub-customers and jobs associated with it. | | ||
|
||
|
||
## Initialize the pipeline with Quickbooks verified source | ||
```bash | ||
dlt init quickbooks_online duckdb | ||
``` | ||
|
||
Here, we chose DuckDB as the destination. Alternatively, you can also choose redshift, snowflake, or any of the other [destinations.](https://dlthub.com/docs/dlt-ecosystem/destinations/) | ||
|
||
## Setup verified source and pipeline example | ||
|
||
To grab credentials and initialize the verified source, please refer to the [full documentation here.](https://dlthub.com/docs/dlt-ecosystem/verified-sources/salesforce) | ||
|
||
## Add credentials | ||
|
||
1. Open `.dlt/secrets.toml`. | ||
2. Put these credentials in, these can be sourced from quickbooks developer portal and quickbooks oauth playground: | ||
```toml | ||
# put your secret values and credentials here. do not share this file and do not push it to github | ||
[sources.quickbooks_online] | ||
environment="" | ||
company_id="" | ||
client_id="" | ||
client_secret="" | ||
access_token="" | ||
refresh_token="" | ||
redirect_uri="" | ||
``` | ||
|
||
3. Enter credentials for your chosen destination as per the [docs.](https://dlthub.com/docs/dlt-ecosystem/destinations/) | ||
|
||
## Run the pipeline example | ||
|
||
1. Install the necessary dependencies by running the following command: | ||
```bash | ||
pip install -r requirements.txt | ||
``` | ||
|
||
2. Now the pipeline can be run by using the command: | ||
```bash | ||
python3 quickbooks_online_pipeline.py | ||
``` | ||
|
||
3. To make sure that everything is loaded as expected, use the command: | ||
```bash | ||
dlt pipeline <pipeline_name> show | ||
``` | ||
|
||
For example, the pipeline_name for the above pipeline is `quickbooks_online`, you may also use any custom name instead. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
"""Source for Quickbooks depending on the quickbooks_online-python python package. | ||
|
||
Quickbooks-python docs: https://github.com/ej2/python-quickbooks | ||
Quickbooks api docs: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/ | ||
Quickbooks company id: https://quickbooks.intuit.com/learn-support/en-uk/help-article/customer-company-settings/find-quickbooks-online-company-id/L7lp8O9yU_GB_en_GB | ||
To get API credentials: https://developer.intuit.com/app/developer/qbo/docs/get-started/start-developing-your-app | ||
Get oAuth Authorization code from: https://developer.intuit.com/app/developer/playground | ||
""" | ||
|
||
from dlt.sources import DltResource | ||
|
||
from typing import Iterable, Sequence | ||
|
||
import dlt | ||
from .oauth_setup import QuickBooksAuth | ||
from .settings import sandbox_env, production_env | ||
from dlt.common.typing import TDataItem | ||
from intuitlib.client import AuthClient | ||
from quickbooks import QuickBooks | ||
from quickbooks.objects.customer import Customer | ||
from quickbooks.objects.invoice import Invoice | ||
|
||
|
||
@dlt.source(name="quickbooks_online") | ||
def quickbooks_online( | ||
environment: str = sandbox_env, | ||
|
||
client_id: str = dlt.secrets.value, | ||
client_secret: str = dlt.secrets.value, | ||
refresh_token: str = dlt.secrets.value, | ||
company_id: str = dlt.secrets.value, | ||
redirect_url: str = dlt.secrets.value, | ||
) -> Sequence[DltResource]: | ||
""" | ||
Retrieves data from Quickbooks using the Quickbooks API. | ||
|
||
Args: | ||
environment (str): The environment used for authentication, choose variable sandbox_env | production_env | ||
client_id (str): The client id provided by quickbooks for authentication. Defaults to the value in the `dlt.secrets` object. | ||
client_secret (str): The client secret provided by quickbooks for authentication. Defaults to the value in the `dlt.secrets` object. | ||
refresh_token (str): The refresh token given a quickbooks scope. Defaults to the value in the `dlt.secrets` object. | ||
company_id (str): The company id / realm id provided by quickbooks. Defaults to the value in the `dlt.secrets` object. | ||
redirect_url (str): The redirect uri end user creates in quickbooks, found in the developer application created. Defaults to the value in the `dlt.secrets` object. | ||
Yields: | ||
DltResource: Data resources from Quickbooks. | ||
""" | ||
|
||
bearer_access_token = QuickBooksAuth( | ||
client_id=client_id, | ||
client_secret=client_secret, | ||
company_id=company_id, | ||
redirect_url=redirect_url, | ||
refresh_token=refresh_token, | ||
is_sandbox=False if environment == production_env else True, | ||
).get_bearer_token_from_refresh_token() | ||
|
||
auth_client = AuthClient( | ||
client_id=client_id, | ||
client_secret=client_secret, | ||
environment=environment, | ||
redirect_uri=redirect_url, | ||
access_token=bearer_access_token.accessToken, | ||
) | ||
|
||
client = QuickBooks( | ||
auth_client=auth_client, refresh_token=refresh_token, company_id=company_id | ||
) | ||
|
||
# define resources | ||
@dlt.resource | ||
def customer() -> Iterable[TDataItem]: | ||
customer = Customer.all(qb=client) # returns a list of iterables | ||
for record in customer: | ||
yield record.to_dict() | ||
|
||
@dlt.resource | ||
def invoice() -> Iterable[TDataItem]: | ||
invoice = Invoice.all(qb=client) | ||
for record in invoice: | ||
yield record.to_dict() | ||
|
||
return [customer, invoice] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
from dlt.sources.helpers import requests | ||
import base64 | ||
import json | ||
import random | ||
from typing import Union | ||
from urllib.parse import urlencode | ||
from .settings import ( | ||
discovery_document_url_sandbox, | ||
discovery_document_url_prod, | ||
Scopes, | ||
) | ||
|
||
|
||
class OAuth2Config: | ||
def __init__( | ||
self, | ||
issuer: str = "", | ||
auth_endpoint: str = "", | ||
token_endpoint: str = "", | ||
userinfo_endpoint: str = "", | ||
revoke_endpoint: str = "", | ||
jwks_uri: str = "", | ||
): | ||
self.issuer = issuer | ||
self.auth_endpoint = auth_endpoint | ||
self.token_endpoint = token_endpoint | ||
self.userinfo_endpoint = userinfo_endpoint | ||
self.revoke_endpoint = revoke_endpoint | ||
self.jwks_uri = jwks_uri | ||
|
||
|
||
class Bearer: | ||
def __init__( | ||
self, | ||
refresh_expiry: str, | ||
access_token: str, | ||
token_type: str, | ||
refresh_token: str, | ||
access_token_expiry: str, | ||
id_token: Union[str, None] = None, | ||
): | ||
self.refreshExpiry = refresh_expiry | ||
self.accessToken = access_token | ||
self.tokenType = token_type | ||
self.refreshToken = refresh_token | ||
self.accessTokenExpiry = access_token_expiry | ||
self.idToken = id_token | ||
|
||
|
||
class QuickBooksAuth: | ||
def __init__( | ||
self, | ||
client_id: str, | ||
client_secret: str, | ||
company_id: str, | ||
redirect_url: str, | ||
refresh_token: str = None, | ||
is_sandbox: Union[bool, None] = True, | ||
): | ||
""" | ||
End user should use this class to generate refresh token once manually and store in secrets.toml | ||
and continually use it to generate access tokens | ||
|
||
Should the user need to change scopes, then this should be generated again and stored safely | ||
|
||
Source code used is from: https://github.com/IntuitDeveloper/OAuth2PythonSampleApp/blob/master/sampleAppOAuth2/services.py | ||
""" | ||
self.is_sandbox = is_sandbox or None | ||
self.client_id = client_id | ||
self.client_secret = client_secret | ||
self.company_id = company_id | ||
self.redirect_url = redirect_url | ||
self.refresh_token = refresh_token | ||
|
||
@staticmethod | ||
def string_to_base64(s: str) -> str: | ||
return base64.b64encode(bytes(s, "utf-8")).decode() | ||
|
||
@staticmethod | ||
def get_random_string( | ||
length: int = 64, | ||
allowed_chars: str = "abcdefghijklmnopqrstuvwxyz" | ||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", | ||
) -> str: | ||
return "".join(random.choice(allowed_chars) for i in range(length)) | ||
|
||
def get_discovery_document(self) -> OAuth2Config: | ||
if self.is_sandbox: | ||
discovery_document_url = discovery_document_url_sandbox | ||
else: | ||
discovery_document_url = discovery_document_url_prod | ||
r = requests.get(discovery_document_url) | ||
if r.status_code >= 400: | ||
raise ConnectionError(r.json()) | ||
|
||
discovery_doc_json = r.json() | ||
discovery_doc = OAuth2Config( | ||
issuer=discovery_doc_json["issuer"], | ||
auth_endpoint=discovery_doc_json["authorization_endpoint"], | ||
userinfo_endpoint=discovery_doc_json["userinfo_endpoint"], | ||
revoke_endpoint=discovery_doc_json["revocation_endpoint"], | ||
token_endpoint=discovery_doc_json["token_endpoint"], | ||
jwks_uri=discovery_doc_json["jwks_uri"], | ||
) | ||
|
||
return discovery_doc | ||
|
||
def get_auth_url(self, scope: Union[str, Scopes]) -> str: | ||
""" | ||
scopes available in settings.py from intuitlib.enums | ||
""" | ||
auth_endpoint = self.get_discovery_document().auth_endpoint | ||
auth_url_params = { | ||
"client_id": self.client_id, | ||
"redirect_uri": self.redirect_url, | ||
"response_type": "code", | ||
"scope": scope, | ||
"state": self.get_random_string(), | ||
} | ||
url = f"{auth_endpoint}?{urlencode(auth_url_params)}" | ||
|
||
return url | ||
|
||
def get_bearer_token( | ||
self, auth_code: str, client_id: str, client_secret: str, redirect_uri: str | ||
) -> Union[str, Bearer]: | ||
token_endpoint = self.get_discovery_document().token_endpoint | ||
auth_header = "Basic " + self.string_to_base64(client_id + ":" + client_secret) | ||
headers = { | ||
"Accept": "application/json", | ||
"content-type": "application/x-www-form-urlencoded", | ||
"Authorization": auth_header, | ||
} | ||
payload = { | ||
"code": auth_code, | ||
"redirect_uri": redirect_uri, | ||
"grant_type": "authorization_code", | ||
} | ||
r = requests.post(token_endpoint, data=payload, headers=headers) | ||
if r.status_code != 200: | ||
return r.text | ||
bearer_raw = json.loads(r.text) | ||
|
||
if "id_token" in bearer_raw: | ||
id_token = bearer_raw["id_token"] | ||
else: | ||
id_token = None | ||
|
||
return Bearer( | ||
bearer_raw["x_refresh_token_expires_in"], | ||
bearer_raw["access_token"], | ||
bearer_raw["token_type"], | ||
bearer_raw["refresh_token"], | ||
bearer_raw["expires_in"], | ||
id_token=id_token, | ||
) | ||
|
||
def get_bearer_token_from_refresh_token(self) -> Bearer: | ||
token_endpoint = self.get_discovery_document().token_endpoint | ||
auth_header = "Basic " + self.string_to_base64( | ||
self.client_id + ":" + self.client_secret | ||
) | ||
headers = { | ||
"Accept": "application/json", | ||
"content-type": "application/x-www-form-urlencoded", | ||
"Authorization": auth_header, | ||
} | ||
|
||
payload = {"refresh_token": self.refresh_token, "grant_type": "refresh_token"} | ||
r = requests.post(token_endpoint, data=payload, headers=headers) | ||
bearer_raw = json.loads(r.text) | ||
|
||
if "id_token" in bearer_raw: | ||
id_token = bearer_raw["id_token"] | ||
else: | ||
id_token = None | ||
|
||
return Bearer( | ||
bearer_raw["x_refresh_token_expires_in"], | ||
bearer_raw["access_token"], | ||
bearer_raw["token_type"], | ||
bearer_raw["refresh_token"], | ||
bearer_raw["expires_in"], | ||
id_token=id_token, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dlt>=0.5.1 | ||
python-quickbooks>=0.9.12 | ||
intuit-oauth==1.2.6 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
from intuitlib.enums import Scopes | ||
|
||
discovery_document_url_sandbox = ( | ||
"https://developer.api.intuit.com/.well-known/openid_sandbox_configuration" | ||
) | ||
discovery_document_url_prod = ( | ||
"https://developer.api.intuit.com/.well-known/openid_configuration" | ||
) | ||
|
||
sandbox_env = "sandbox" | ||
production_env = "production" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import dlt | ||
from quickbooks_online import quickbooks_online | ||
|
||
|
||
def load_customer() -> None: | ||
pipeline = dlt.pipeline( | ||
pipeline_name="quickbooks_customer", | ||
destination="duckdb", | ||
dataset_name="quickbooks_online", | ||
) | ||
load_info = pipeline.run(quickbooks_online()) | ||
print(load_info) | ||
|
||
|
||
if __name__ == "__main__": | ||
load_customer() |
Uh oh!
There was an error while loading. Please reload this page.