diff --git a/FlaskApp/app.py b/FlaskApp/app.py index 505a1f3..5e756a6 100644 --- a/FlaskApp/app.py +++ b/FlaskApp/app.py @@ -1,11 +1,16 @@ from flask import Flask, render_template, redirect, request, session, abort, make_response, flash -import hmac, json, hashlib, requests, secrets, uuid, datetime +import hmac +import json +import hashlib +import requests +import secrets +import uuid +import datetime import config from intuitlib.client import AuthClient from intuitlib.enums import Scopes from quickbooks import QuickBooks from quickbooks.objects.customer import Customer -#from asana.rest import ApiException from pprint import pprint from urllib.parse import urlencode from quickbooks.objects.item import Item @@ -34,36 +39,38 @@ # Define the scopes scopes = [ Scopes.ACCOUNTING, - Scopes.PROJECT_MANAGEMENT + Scopes.PROJECTS ] auth_url = auth_client.get_authorization_url(scopes) -@app.route('/qbo-login', methods = ['GET']) +@app.route('/qbo-login', methods=['GET']) def button(): - + return redirect(auth_url) + def getAuthHeader(): auth_header = 'Bearer {0}'.format(auth_client.access_token) return auth_header # get qbo access token -@app.route('/callback', methods=['GET','POST']) + + +@app.route('/callback', methods=['GET', 'POST']) def qboCallback(): try: - + auth_code = request.args.get('code') realm_id = request.args.get('realmId') - + auth_client.get_bearer_token(auth_code, realm_id=realm_id) - - + session['new_token'] = auth_client.refresh_token session['realm_id'] = auth_client.realm_id session['auth_header'] = f'Bearer {auth_client.access_token}' - + flash('Successfully connected to QuickBooks') return redirect('/') @@ -76,37 +83,42 @@ def qboCallback(): custName = [] custDict = {} # post data to QBO -@app.route("/call-qbo",methods=['GET','POST']) + + +@app.route("/call-qbo", methods=['GET', 'POST']) def callQbo(): try: qbo_service = QuickBooksService( auth_header=session['auth_header'], realm_id=session['realm_id'] ) - - global custDict + + global custDict, custName custDict = qbo_service.get_customers() custName = qbo_service.get_customer_names() - + return render_template('index.html', customers=custName) except Exception as e: logger.error(f"Error in callQbo: {str(e)}") flash('Error fetching customers. Please try again.') return redirect('/') -@app.route("/create-projects",methods=['GET','POST']) + +@app.route("/create-projects", methods=['GET', 'POST']) def getProjects(): try: + global selected_custName, custID selected_custName = request.form.get('customers') if not selected_custName: flash('Please select a customer') return redirect('/') - - custID = next((k for k, v in custDict.items() if v == selected_custName), None) + + custID = next((k for k, v in custDict.items() + if v == selected_custName), None) if not custID: flash('Invalid customer selected') return redirect('/') - + transport = RequestsHTTPTransport( url='https://qb.api.intuit.com/graphql', headers={ @@ -114,42 +126,132 @@ def getProjects(): 'Accept': 'application/json' } ) - + client = Client(transport=transport, fetch_schema_from_transport=False) - + # Read the GraphQL mutation from file with open('static/graphql/project.graphql', 'r') as f: query = gql(f.read()) - + # Prepare variables using the service variables = prepare_variables(selected_custName, custID) - + result = client.execute(query, variable_values=variables) + global projects projects = result.get('projectManagementCreateProject', {}) - + if not projects: flash('Failed to create project') return redirect('/') - + flash('Project created successfully') return render_template('index.html', customers=custName, project=projects) - + except Exception as e: logger.error(f"Error creating project: {str(e)}") flash('Error creating project. Please try again.') return redirect('/') -# receive events from asana -#hook_secret = None +@app.route("/create-invoice", methods=['GET', 'POST']) +def createInvoice(): + try: + """ selected_custName = request.form.get('customers') + if not selected_custName: + flash('Please select a customer') + return redirect('/') + + custID = next((k for k, v in custDict.items() + if v == selected_custName), None) + if not custID: + flash('Invalid customer selected') + return redirect('/') """ + + qbo_service = QuickBooksService( + auth_header=session['auth_header'], + realm_id=session['realm_id'] + ) + + logger.info(f"Projects variable:::::::::::::::: {projects}") + customer_id = projects['customer']['id'] + global invoiceDict + invoiceDict = qbo_service.create_invoice( + customer_id=customer_id, + amount=199.00, + description="Testing invoice with Project reference", + project_id=projects['id'] if projects and 'id' in projects else None + ) -#@app.route("/create-webhook", methods=["GET", 'POST']) -@app.route('/', methods =['GET', 'POST'] ) + if not invoiceDict: + flash('Failed to create invoice') + return redirect('/') + + flash('Invoice created successfully') + logger.info(f"Invoice response: {invoiceDict}") + + invoice_id = invoiceDict.get('Invoice', {}).get('Id') + invoiceDeepLink = f"https://app.qbo.intuit.com/app/invoice?txnId={invoice_id}&companyId={session['realm_id']}" + return render_template('index.html', customers=custName, invoice=invoiceDict.get('Invoice', {}), invoiceDeepLink=invoiceDeepLink) + + except Exception as e: + logger.error(f"Error creating Invoice: {str(e)}") + flash('Error creating Invoice. Please try again.') + return redirect('/') + + +@app.route("/call-projects", methods=['GET', 'POST']) +def callProjects(): + try: + transport = RequestsHTTPTransport( + url='https://qb.api.intuit.com/graphql', + headers={ + 'Authorization': session['auth_header'], + 'Accept': 'application/json' + } + ) + + client = Client(transport=transport, fetch_schema_from_transport=False) + + # Read the GraphQL Query from file + with open('static/graphql/get_projects.graphql', 'r') as f: + query = gql(f.read()) + + # Prepare variables + + variables = { + "first": 4, + "filter": { + "status": { + "equals": None + } + }, + "orderBy": ["DUE_DATE_DESC"] + } + + result = client.execute(query, variable_values=variables) + projects = result.get('projectManagementProjects', {}) + + if not projects: + flash('Failed to fetch projects') + return redirect('/') + + flash('Projects fetched successfully') + edges = projects.get('edges', []) + project_list = [edge['node'] for edge in edges] + return render_template('index.html', projectList=project_list) + + except Exception as e: + logger.error(f"Error in callProjects: {str(e)}") + flash('Error fetching Projects. Please try again.') + return redirect('/') + + +@app.route('/', methods=['GET', 'POST']) def home(): return render_template('index.html') -#print(auth_url) -#app.run(port=5001) + +# print(auth_url) +# app.run(port=5001) if __name__ == '__main__': app.run(host='0.0.0.0', port=5001) - diff --git a/FlaskApp/quickbooks_service.py b/FlaskApp/quickbooks_service.py index 1c5e0ed..bdd906d 100644 --- a/FlaskApp/quickbooks_service.py +++ b/FlaskApp/quickbooks_service.py @@ -5,32 +5,34 @@ logger = logging.getLogger(__name__) + class QuickBooksService: """Service for handling QuickBooks API interactions""" - + BASE_URL = 'https://quickbooks.api.intuit.com' - + def __init__(self, auth_header: str, realm_id: str): self.auth_header = auth_header self.realm_id = realm_id - - def _get_headers(self) -> Dict[str, str]: - """Get common headers for QuickBooks API requests""" + + def _get_headers(self, content_type: str = 'application/json') -> Dict[str, str]: + """Get common headers for QuickBooks API requests, dynamically set Content-Type""" return { 'Authorization': self.auth_header, 'Accept': 'application/json', - 'Content-Type': 'application/text' + 'Content-Type': content_type } - - def _make_request(self, endpoint: str, method: str = "POST", data: Optional[str] = None) -> Dict: + + def _make_request(self, endpoint: str, content_type: str, method: str = "POST", data: Optional[str] = None) -> Dict: """ Make a request to QuickBooks API - + Args: endpoint: API endpoint + content_type: Content-Type header value method: HTTP method data: Request data - + Returns: Dict containing API response """ @@ -39,7 +41,7 @@ def _make_request(self, endpoint: str, method: str = "POST", data: Optional[str] response = requests.request( method, url, - headers=self._get_headers(), + headers=self._get_headers(content_type), data=data ) response.raise_for_status() @@ -47,21 +49,21 @@ def _make_request(self, endpoint: str, method: str = "POST", data: Optional[str] except requests.exceptions.RequestException as e: logger.error(f"QuickBooks API request failed: {str(e)}") raise - + def get_customers(self) -> Dict[str, str]: """ Fetch customers from QuickBooks - + Returns: Dict mapping customer IDs to their fully qualified names """ try: endpoint = f'/v3/company/{self.realm_id}/query' query = "Select * from Customer where Job = false" - - data = self._make_request(endpoint, data=query) + + data = self._make_request(endpoint, "application/text", data=query) customers = data['QueryResponse']['Customer'] - + return { customer['Id']: customer['FullyQualifiedName'] for customer in customers @@ -70,13 +72,97 @@ def get_customers(self) -> Dict[str, str]: except Exception as e: logger.error(f"Error fetching customers: {str(e)}") raise - + def get_customer_names(self) -> List[str]: """ Get list of customer names - + Returns: List of customer names """ customers = self.get_customers() - return list(customers.values()) \ No newline at end of file + return list(customers.values()) + + def get_projects(self) -> Dict[str, str]: + """ + Fetch projects from QuickBooks + + Returns: + projects + """ + try: + endpoint = f'https://qb.api.intuit.com/graphql' + query = "{\"query\":\"query projectManagementProjects(\\n $first: PositiveInt!,\\n $after: String,\\n $filter: ProjectManagement_ProjectFilter!,\\n $orderBy: [ProjectManagement_OrderBy!]\\n) {\\n projectManagementProjects(\\n first: $first,\\n after: $after,\\n filter: $filter,\\n orderBy: $orderBy\\n ) {\\n edges {\\n node {\\n id,\\n name,\\n description,\\n type,\\n status,\\n startDate,\\n completedDate,\\n dueDate,\\n assignee{\\n id\\n },\\n priority,\\n customer{\\n id\\n },\\n account{\\n id\\n }\\n addresses {\\n streetAddressLine1,\\n streetAddressLine2,\\n streetAddressLine3\\n state,\\n postalCode\\n } \\n }\\n }\\n pageInfo {\\n hasNextPage\\n hasPreviousPage\\n startCursor\\n endCursor\\n }\\n }\\n}\\n\",\"variables\":{\"first\":4,\"filter\":{\"status\":{\"equals\":null}},\"orderBy\":[\"DUE_DATE_DESC\"]}}" + + data = self._make_request(endpoint, data=query) + projects = data['data']['projectManagementProjects'] + + return { + edges['node']['id']: edges['node']['name'] + for edges in projects['edges'] + if 'id' in edges['node'] + } + except Exception as e: + logger.error(f"Error fetching projects: {str(e)}") + raise + + def get_project_names(self) -> List[str]: + """ + Get list of project names + + Returns: + List of project names + """ + projects = self.get_projects() + return list(projects.values()) + + def create_invoice(self, customer_id: str, amount: float, description: str, project_id: str) -> Dict[str, str]: + """ + Create an invoice in QuickBooks + + Returns: + created invoice details + """ + try: + endpoint = f'/v3/company/{self.realm_id}/invoice?minorversion=75' + invoice_data = { + "CustomerRef": { + "value": customer_id + }, + "CurrencyRef": { + "value": "USD" + }, + "Line": [ + { + "Id": "1", + "LineNum": 1, + "Amount": amount, + "Description": description, + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": { + "ItemRef": { + "value": "1" + }, + "UnitPrice": 199, + "Qty": 1, + "ItemAccountRef": { + "name": "Sales" + }, + "TaxCodeRef": { + "value": "NON" + } + } + } + ], + "ProjectRef": { + "value": project_id + } + } + invoice_data = json.dumps(invoice_data) + + data = self._make_request( + endpoint, "application/json", "POST", data=invoice_data) + return data + except Exception as e: + logger.error(f"Error creating invoice: {str(e)}") + raise diff --git a/FlaskApp/requirements.txt b/FlaskApp/requirements.txt index bbe6985..963fc52 100644 --- a/FlaskApp/requirements.txt +++ b/FlaskApp/requirements.txt @@ -1,4 +1,5 @@ flask==2.0.1 +werkzeug>=2.0,<2.1 requests==2.26.0 gql==3.0.0 pyjwt==2.4.0 diff --git a/FlaskApp/static/graphql/get_projects.graphql b/FlaskApp/static/graphql/get_projects.graphql new file mode 100644 index 0000000..ae89753 --- /dev/null +++ b/FlaskApp/static/graphql/get_projects.graphql @@ -0,0 +1,49 @@ +query projectManagementProjects( + $first: PositiveInt!, + $after: String, + $filter: ProjectManagement_ProjectFilter!, + $orderBy: [ProjectManagement_OrderBy!] +) { + projectManagementProjects( + first: $first, + after: $after, + filter: $filter, + orderBy: $orderBy + ) { + edges { + node { + id, + name, + description, + type, + status, + startDate, + completedDate, + dueDate, + assignee{ + id + }, + priority, + customer{ + id + }, + account{ + id + } + addresses { + streetAddressLine1, + streetAddressLine2, + streetAddressLine3 + state, + postalCode + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } +} \ No newline at end of file diff --git a/FlaskApp/static/styles.css b/FlaskApp/static/styles.css index aa39934..42a8ea4 100644 --- a/FlaskApp/static/styles.css +++ b/FlaskApp/static/styles.css @@ -33,7 +33,7 @@ body { background: white; padding: 30px; border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .step-container h2 { @@ -77,7 +77,8 @@ select { .action-btn { width: 100%; padding: 12px; - background-color: #c41e3a; /* Professional red color */ + background-color: #c41e3a; + /* Professional red color */ color: white; border: none; border-radius: 4px; @@ -87,7 +88,8 @@ select { } .action-btn:hover { - background-color: #a01830; /* Darker shade for hover */ + background-color: #a01830; + /* Darker shade for hover */ color: white; } @@ -134,70 +136,80 @@ select { .container { padding: 10px; } - + .step-container { padding: 15px; } - + .step { padding: 15px; } } -input[name='qbo_connect'] { - color: white; - background-color: #ff4444; - font-size: xx-large; +input[name='qbo_connect'] { + color: white; + background-color: #ff4444; + font-size: xx-large; } + input { margin-bottom: 20px; } -/* -input[name='asana_connect']{ - background-color: white; color: red; font-size: xx-large; -} */ + body { - /* display: flex; */ + /* display: flex; */ justify-content: center; - margin: 0; - padding: 0; + margin: 0; + padding: 0; align-items: center; - background-color: #f4f4f4; /* Soft light gray background */ + background-color: #f4f4f4; + /* Soft light gray background */ min-height: 100vh; font-family: 'Helvetica' } form div p { font-size: 16px; - color: #555; /* Lighter text color */ + color: #555; + /* Lighter text color */ margin: 0; font-family: 'Helvetica' } form { - background-color: #fff; /* White background for the form */ + background-color: #fff; + /* White background for the form */ padding: 20px; - border-radius: 10px; /* Rounded corners */ - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* Subtle shadow */ + border-radius: 10px; + /* Rounded corners */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + /* Subtle shadow */ width: 100%; - max-width: 500px; /* Limit form width */ + max-width: 500px; + /* Limit form width */ margin-bottom: 20px; display: flex; - flex-direction: column; + flex-direction: column; font-family: 'Helvetica' } form div { -display: flex; -align-items: center; /* Vertically center the label and select */ -justify-content: flex-start; -gap: 10px; /* Space between the label and dropdown */ -display: flex; -flex-direction: column; /* Stack the child elements vertically */ -gap: 15px; /* Add space between label and input */ -width: 100%; /* Full width */ -font-family: 'Helvetica' + display: flex; + align-items: center; + /* Vertically center the label and select */ + justify-content: flex-start; + gap: 10px; + /* Space between the label and dropdown */ + display: flex; + flex-direction: column; + /* Stack the child elements vertically */ + gap: 15px; + /* Add space between label and input */ + width: 100%; + /* Full width */ + font-family: 'Helvetica' } + /* div { @@ -205,41 +217,55 @@ font-family: 'Helvetica' font-family: 'Helvetica'; max-height: 200px; /* Adjust based on the desired height } -*/ -input[name='qbo_project'] { - color: white; - background-color: #ff4444; - font-size: xx-large; +*/ +input[name='qbo_project'] { + color: white; + background-color: #ff4444; + font-size: xx-large; } .submit-btn img { - display: block; /* Remove extra space below the image */ - max-width: 100%; /* Ensure image doesn't overflow */ + display: block; + /* Remove extra space below the image */ + max-width: 100%; + /* Ensure image doesn't overflow */ } -.proj-btn { +.proj-btn { border-radius: 6px; - color: white; background-color: rgb(139, 0, 0); font-size:xx-large; + color: white; + background-color: rgb(139, 0, 0); + font-size: xx-large; } -label[name='qbo_selectcust'] { +label[name='qbo_selectcust'] { font-family: 'Helvetica'; - height: 40px; /* Adjust dropdown height */ - width: 200px; /* Adjust width */ - line-height: 40px; /* Vertically center the text */ - text-align: center; /* Horizontally center the text */ - padding: 0; /* Remove extra padding */ + height: 40px; + /* Adjust dropdown height */ + width: 200px; + /* Adjust width */ + line-height: 40px; + /* Vertically center the text */ + text-align: center; + /* Horizontally center the text */ + padding: 0; + /* Remove extra padding */ border: 3px solid white; - color: RGB(139, 0, 0); background-color: white; font-size:x-large; + color: RGB(139, 0, 0); + background-color: white; + font-size: x-large; display: block; margin-bottom: 10px; font-weight: 600; } + select { font-family: 'Helvetica', sans-serif; font-size: medium; - width: 100%; /* Full width for the dropdown */ - height: 40px; /* Set a fixed height */ + width: 100%; + /* Full width for the dropdown */ + height: 40px; + /* Set a fixed height */ padding: 8px 10px; text-align: center; border-radius: 5px; @@ -247,7 +273,95 @@ select { background-color: #f9f9f9; color: #333; margin: 0; - max-height: 150px; /* Limit the height of the dropdown to avoid overflow */ + max-height: 150px; + /* Limit the height of the dropdown to avoid overflow */ overflow-y: auto; - + +} + +.my-table { + border-collapse: collapse; + width: 100%; + margin-top: 20px; +} + +.my-table th, +.my-table td { + border: 1px solid #ddd; + padding: 8px; +} + +.my-table th { + background-color: #343a40; + color: white; +} + +.my-table tr:nth-child(even) { + background-color: #f2f2f2; +} + +.my-table tr:hover { + background-color: #ddd; +} + +/* Invoice details styling */ +.invoice-details { + background-color: #e3f2fd; + padding: 20px; + border-radius: 6px; + border: 1px solid #90caf9; + margin-top: 20px; +} + +.invoice-details h3 { + margin: 0 0 15px 0; + color: #1565c0; +} + +.invoice-info { + background: white; + padding: 15px; + border-radius: 4px; +} + +.invoice-info p { + margin: 8px 0; + padding: 0; +} + +/* Deep link styling */ +.deep-link-section { + margin-top: 15px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 4px; + border: 1px solid #dee2e6; + text-align: center; +} + +.deep-link-btn { + display: inline-block; + padding: 12px 24px; + background-color: #0077c5; + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 600; + font-size: 16px; + transition: background-color 0.3s, transform 0.2s; + margin-bottom: 10px; +} + +.deep-link-btn:hover { + background-color: #005a9e; + transform: translateY(-1px); + text-decoration: none; + color: white; +} + +.deep-link-info { + font-size: 14px; + color: #6c757d; + margin: 0; + font-style: italic; } \ No newline at end of file diff --git a/FlaskApp/templates/index.html b/FlaskApp/templates/index.html index 06ccffa..d1cf70a 100644 --- a/FlaskApp/templates/index.html +++ b/FlaskApp/templates/index.html @@ -1,31 +1,34 @@ + QuickBooks Projects Demo +
{% with messages = get_flashed_messages() %} - {% if messages %} -
- {% for message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} {% endwith %}

QuickBooks Projects Demo

- +

Step 1: Connect to QuickBooks

@@ -45,7 +48,7 @@

Step 3: Create Project

@@ -61,10 +64,63 @@

Project Created Successfully

Name: {{ project.name }}

Status: {{ project.status }}

Due Date: {{ project.dueDate }}

+

Customer ID: {{ project.customer.id }}

+
+ + + +
+

Step 4: Create Invoice for this Project

+
+ +
+
+ {% endif %} + + {% if invoice %} +
+

Invoice Created Successfully

+
+

Invoice ID: {{ invoice['Id'] }}

+

Transaction Date: {{ invoice['TxnDate'] }}

+

Linked to Project:: {{ invoice['CustomerRef']['name'] }}

+
{% endif %} + +
+

List Projects

+
+ +
+
+ + {% if projectList %} +
+ + + + + + + {% for projectitem in projectList %} + + + + + + {% endfor %} +
Project IdName of the ProjectDescription
{{ projectitem.id }}{{ projectitem.name }}{{ projectitem.description }}
+
+ {% endif %} - + \ No newline at end of file