- 
                Notifications
    You must be signed in to change notification settings 
- Fork 74
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
          
     Open
      
      
            ah12068
  wants to merge
  14
  commits into
  dlt-hub:master
  
    
      
        
          
  
    
      Choose a base branch
      
     
    
      
        
      
      
        
          
          
        
        
          
            
              
              
              
  
           
        
        
          
            
              
              
           
        
       
     
  
        
          
            
          
            
          
        
       
    
      
from
ah12068:quickbooks
  
      
      
   
  
    
  
  
  
 
  
      
    base: master
Could not load branches
            
              
  
    Branch not found: {{ refName }}
  
            
                
      Loading
              
            Could not load tags
            
            
              Nothing to show
            
              
  
            
                
      Loading
              
            Are you sure you want to change the base?
            Some commits from the old base branch may be removed from the timeline,
            and old review comments may become outdated.
          
          
  
     Open
                    Changes from 13 commits
      Commits
    
    
            Show all changes
          
          
            14 commits
          
        
        Select commit
          Hold shift + click to select a range
      
      b806f69
              
                add quickbooks source
              
              
                ah12068 e2f689d
              
                fix readme
              
              
                ah12068 7ea5b24
              
                rename due to circular imports
              
              
                dangjeremy 560f69c
              
                update mypy, add tests, update readme, add versions in requirements, …
              
              
                dangjeremy 0f34463
              
                fix dlt version used
              
              
                dangjeremy 38c626d
              
                fix doc string and syntax
              
              
                dangjeremy 130b4d5
              
                remove comments
              
              
                dangjeremy 8e6b506
              
                run linting
              
              
                dangjeremy 1198241
              
                Merge branch 'dlt-hub:master' into quickbooks
              
              
                ah12068 2beb6ef
              
                update docstring in init
              
              
                dangjeremy e7a7150
              
                add invoice as described in issue
              
              
                dangjeremy 744b20d
              
                change version, add in settings and oauth_setup py
              
              
                dangjeremy 79de24f
              
                update readme, clarify settings and defined use is_sandbox
              
              
                dangjeremy 5a8fbea
              
                action on feedback: update docs & small logical refactor
              
              
                dangjeremy File filter
Filter by extension
Conversations
          Failed to load comments.   
        
        
          
      Loading
        
  Jump to
        
          Jump to file
        
      
      
          Failed to load files.   
        
        
          
      Loading
        
  Diff view
Diff view
There are no files selected for viewing
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # 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] | ||
| company_id="" | ||
| client_id="" | ||
| client_secret="" | ||
| refresh_token="" | ||
| redirect_url="" | ||
| ``` | ||
|  | ||
| 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. | ||
|  | ||
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | 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, | ||
| 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] | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | 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, | ||
| ) | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | 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 | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| 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" | ||
| ) | ||
|  | ||
| # comes directly from quickbooks https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0#authorization-request | ||
| sandbox_env = "sandbox" | ||
| production_env = "production" | 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
              | Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import dlt | ||
| from quickbooks_online import quickbooks_online | ||
| from quickbooks_online.settings import sandbox_env, production_env | ||
|  | ||
|  | ||
| def load_customer() -> None: | ||
| pipeline = dlt.pipeline( | ||
| pipeline_name="quickbooks_customer", | ||
| destination="duckdb", | ||
| dataset_name="quickbooks_online", | ||
| ) | ||
| load_info = pipeline.run(quickbooks_online(environment=sandbox_env)) | ||
| print(load_info) | ||
|  | ||
|  | ||
| if __name__ == "__main__": | ||
| load_customer() | 
              Empty file.
          
    
      
      Oops, something went wrong.
        
    
  
  Add this suggestion to a batch that can be applied as a single commit.
  This suggestion is invalid because no changes were made to the code.
  Suggestions cannot be applied while the pull request is closed.
  Suggestions cannot be applied while viewing a subset of changes.
  Only one suggestion per line can be applied in a batch.
  Add this suggestion to a batch that can be applied as a single commit.
  Applying suggestions on deleted lines is not supported.
  You must change the existing code in this line in order to create a valid suggestion.
  Outdated suggestions cannot be applied.
  This suggestion has been applied or marked resolved.
  Suggestions cannot be applied from pending reviews.
  Suggestions cannot be applied on multi-line comments.
  Suggestions cannot be applied while the pull request is queued to merge.
  Suggestion cannot be applied right now. Please check back later.
  
    
  
    
Uh oh!
There was an error while loading. Please reload this page.