mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-30 17:29:50 -05:00
3218ab012a
Add new client portal pages (dashboard, approvals, notifications, documents, reports) and extend API/routes/services to support client approvals, invoices/quotes views, and related notifications. Update email templates and docs; add/adjust tests for new models/routes.
757 lines
36 KiB
Python
757 lines
36 KiB
Python
"""
|
|
QuickBooks integration connector.
|
|
Sync invoices, expenses, and payments with QuickBooks Online.
|
|
"""
|
|
|
|
from typing import Dict, Any, Optional, List
|
|
from datetime import datetime, timedelta
|
|
from app.integrations.base import BaseConnector
|
|
import requests
|
|
import os
|
|
import base64
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class QuickBooksConnector(BaseConnector):
|
|
"""QuickBooks Online integration connector."""
|
|
|
|
display_name = "QuickBooks Online"
|
|
description = "Sync invoices, expenses, and payments with QuickBooks"
|
|
icon = "quickbooks"
|
|
|
|
BASE_URL = "https://sandbox-quickbooks.api.intuit.com" # Sandbox
|
|
PRODUCTION_URL = "https://quickbooks.api.intuit.com" # Production
|
|
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return "quickbooks"
|
|
|
|
def get_base_url(self):
|
|
"""Get base URL based on environment"""
|
|
use_sandbox = self.integration.config.get("use_sandbox", True) if self.integration else True
|
|
return self.BASE_URL if use_sandbox else self.PRODUCTION_URL
|
|
|
|
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
|
|
"""Get QuickBooks OAuth authorization URL."""
|
|
from app.models import Settings
|
|
|
|
settings = Settings.get_settings()
|
|
creds = settings.get_integration_credentials("quickbooks")
|
|
client_id = creds.get("client_id") or os.getenv("QUICKBOOKS_CLIENT_ID")
|
|
client_secret = creds.get("client_secret") or os.getenv("QUICKBOOKS_CLIENT_SECRET")
|
|
|
|
if not client_id:
|
|
raise ValueError("QUICKBOOKS_CLIENT_ID not configured")
|
|
|
|
auth_url = "https://appcenter.intuit.com/connect/oauth2"
|
|
|
|
scopes = ["com.intuit.quickbooks.accounting", "com.intuit.quickbooks.payment"]
|
|
|
|
params = {
|
|
"client_id": client_id,
|
|
"scope": " ".join(scopes),
|
|
"redirect_uri": redirect_uri,
|
|
"response_type": "code",
|
|
"access_type": "offline",
|
|
"state": state or "",
|
|
}
|
|
|
|
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
|
|
return f"{auth_url}?{query_string}"
|
|
|
|
def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]:
|
|
"""Exchange authorization code for tokens."""
|
|
from app.models import Settings
|
|
|
|
settings = Settings.get_settings()
|
|
creds = settings.get_integration_credentials("quickbooks")
|
|
client_id = creds.get("client_id") or os.getenv("QUICKBOOKS_CLIENT_ID")
|
|
client_secret = creds.get("client_secret") or os.getenv("QUICKBOOKS_CLIENT_SECRET")
|
|
|
|
if not client_id or not client_secret:
|
|
raise ValueError("QuickBooks OAuth credentials not configured")
|
|
|
|
token_url = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
|
|
|
|
# QuickBooks requires Basic Auth for token exchange
|
|
auth_string = f"{client_id}:{client_secret}"
|
|
auth_bytes = auth_string.encode("ascii")
|
|
auth_b64 = base64.b64encode(auth_bytes).decode("ascii")
|
|
|
|
response = requests.post(
|
|
token_url,
|
|
headers={
|
|
"Authorization": f"Basic {auth_b64}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri},
|
|
)
|
|
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
expires_at = None
|
|
if "expires_in" in data:
|
|
expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"])
|
|
|
|
# Get company info
|
|
company_info = {}
|
|
if "access_token" in data and "realmId" in data:
|
|
try:
|
|
realm_id = data["realmId"]
|
|
company_response = self._api_request(
|
|
"GET", f"/v3/company/{realm_id}/companyinfo/{realm_id}", data.get("access_token"), realm_id
|
|
)
|
|
if company_response:
|
|
company_info = company_response.get("CompanyInfo", {})
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"access_token": data.get("access_token"),
|
|
"refresh_token": data.get("refresh_token"),
|
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
|
"token_type": "Bearer",
|
|
"realm_id": data.get("realmId"), # QuickBooks company ID
|
|
"extra_data": {"company_name": company_info.get("CompanyName", ""), "company_id": data.get("realmId")},
|
|
}
|
|
|
|
def refresh_access_token(self) -> Dict[str, Any]:
|
|
"""Refresh access token using refresh token."""
|
|
if not self.credentials or not self.credentials.refresh_token:
|
|
raise ValueError("No refresh token available")
|
|
|
|
from app.models import Settings
|
|
|
|
settings = Settings.get_settings()
|
|
creds = settings.get_integration_credentials("quickbooks")
|
|
client_id = creds.get("client_id") or os.getenv("QUICKBOOKS_CLIENT_ID")
|
|
client_secret = creds.get("client_secret") or os.getenv("QUICKBOOKS_CLIENT_SECRET")
|
|
|
|
token_url = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
|
|
|
|
auth_string = f"{client_id}:{client_secret}"
|
|
auth_bytes = auth_string.encode("ascii")
|
|
auth_b64 = base64.b64encode(auth_bytes).decode("ascii")
|
|
|
|
response = requests.post(
|
|
token_url,
|
|
headers={
|
|
"Authorization": f"Basic {auth_b64}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
data={"grant_type": "refresh_token", "refresh_token": self.credentials.refresh_token},
|
|
)
|
|
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
expires_at = None
|
|
if "expires_in" in data:
|
|
expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"])
|
|
|
|
# Update credentials
|
|
self.credentials.access_token = data.get("access_token")
|
|
if "refresh_token" in data:
|
|
self.credentials.refresh_token = data.get("refresh_token")
|
|
if expires_at:
|
|
self.credentials.expires_at = expires_at
|
|
self.credentials.save()
|
|
|
|
return {"access_token": data.get("access_token"), "expires_at": expires_at.isoformat() if expires_at else None}
|
|
|
|
def test_connection(self) -> Dict[str, Any]:
|
|
"""Test connection to QuickBooks."""
|
|
try:
|
|
realm_id = self.integration.config.get("realm_id") if self.integration else None
|
|
if not realm_id:
|
|
return {"success": False, "message": "QuickBooks company not configured"}
|
|
|
|
company_info = self._api_request(
|
|
"GET", f"/v3/company/{realm_id}/companyinfo/{realm_id}", self.get_access_token(), realm_id
|
|
)
|
|
|
|
if company_info:
|
|
company_name = company_info.get("CompanyInfo", {}).get("CompanyName", "Unknown")
|
|
return {"success": True, "message": f"Connected to QuickBooks company: {company_name}"}
|
|
else:
|
|
return {"success": False, "message": "Failed to retrieve company information"}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"Connection test failed: {str(e)}"}
|
|
|
|
def _api_request(self, method: str, endpoint: str, access_token: str, realm_id: str, json_data: Optional[Dict] = None) -> Optional[Dict]:
|
|
"""Make API request to QuickBooks"""
|
|
base_url = self.get_base_url()
|
|
url = f"{base_url}{endpoint}"
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
if realm_id:
|
|
headers["realmId"] = realm_id
|
|
|
|
try:
|
|
if method.upper() == "GET":
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
elif method.upper() == "POST":
|
|
response = requests.post(url, headers=headers, timeout=30, json=json_data or {})
|
|
elif method.upper() == "PUT":
|
|
response = requests.put(url, headers=headers, timeout=30, json=json_data or {})
|
|
else:
|
|
response = requests.request(method, url, headers=headers, timeout=30, json=json_data)
|
|
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.exceptions.Timeout:
|
|
logger.error(f"QuickBooks API request timeout: {method} {endpoint}")
|
|
raise ValueError("QuickBooks API request timed out. Please try again.")
|
|
except requests.exceptions.ConnectionError as e:
|
|
logger.error(f"QuickBooks API connection error: {e}")
|
|
raise ValueError(f"Failed to connect to QuickBooks API: {str(e)}")
|
|
except requests.exceptions.HTTPError as e:
|
|
error_detail = ""
|
|
if e.response:
|
|
try:
|
|
error_data = e.response.json()
|
|
error_detail = error_data.get("fault", {}).get("error", [{}])[0].get("detail", "")
|
|
if not error_detail:
|
|
error_detail = error_data.get("fault", {}).get("error", [{}])[0].get("message", "")
|
|
except Exception:
|
|
error_detail = e.response.text[:200] if e.response.text else ""
|
|
|
|
error_msg = f"QuickBooks API error ({e.response.status_code}): {error_detail or str(e)}"
|
|
logger.error(f"QuickBooks API request failed: {error_msg}")
|
|
raise ValueError(error_msg)
|
|
except Exception as e:
|
|
logger.error(f"QuickBooks API request failed: {e}", exc_info=True)
|
|
raise ValueError(f"QuickBooks API request failed: {str(e)}")
|
|
|
|
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
|
|
"""Sync invoices and expenses with QuickBooks"""
|
|
from app.models import Invoice, Expense
|
|
from app import db
|
|
|
|
try:
|
|
realm_id = self.integration.config.get("realm_id")
|
|
if not realm_id:
|
|
return {"success": False, "message": "QuickBooks company not configured"}
|
|
|
|
access_token = self.get_access_token()
|
|
if not access_token:
|
|
return {"success": False, "message": "No access token available. Please reconnect the integration."}
|
|
|
|
synced_count = 0
|
|
errors = []
|
|
|
|
# Sync invoices (create as invoices in QuickBooks)
|
|
if sync_type == "full" or sync_type == "invoices":
|
|
try:
|
|
invoices = Invoice.query.filter(
|
|
Invoice.status.in_(["sent", "paid"]), Invoice.created_at >= datetime.utcnow() - timedelta(days=90)
|
|
).all()
|
|
|
|
for invoice in invoices:
|
|
try:
|
|
# Skip if already synced (has QuickBooks ID)
|
|
if hasattr(invoice, "metadata") and invoice.metadata and invoice.metadata.get("quickbooks_id"):
|
|
continue
|
|
|
|
qb_invoice = self._create_quickbooks_invoice(invoice, access_token, realm_id)
|
|
if qb_invoice:
|
|
# Store QuickBooks ID in invoice metadata
|
|
if not hasattr(invoice, "metadata") or not invoice.metadata:
|
|
invoice.metadata = {}
|
|
invoice.metadata["quickbooks_id"] = qb_invoice.get("Id")
|
|
synced_count += 1
|
|
except ValueError as e:
|
|
# Validation errors - log but continue
|
|
error_msg = f"Invoice {invoice.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.warning(error_msg)
|
|
except requests.exceptions.HTTPError as e:
|
|
# API errors - log with details
|
|
error_msg = f"Invoice {invoice.id}: QuickBooks API error - {e.response.status_code}: {e.response.text[:200] if e.response else str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
except Exception as e:
|
|
# Other errors
|
|
error_msg = f"Invoice {invoice.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
except Exception as e:
|
|
error_msg = f"Error fetching invoices: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
|
|
# Sync expenses (create as expenses in QuickBooks)
|
|
if sync_type == "full" or sync_type == "expenses":
|
|
try:
|
|
expenses = Expense.query.filter(Expense.date >= datetime.utcnow().date() - timedelta(days=90)).all()
|
|
|
|
for expense in expenses:
|
|
try:
|
|
# Skip if already synced
|
|
if hasattr(expense, "metadata") and expense.metadata and expense.metadata.get("quickbooks_id"):
|
|
continue
|
|
|
|
qb_expense = self._create_quickbooks_expense(expense, access_token, realm_id)
|
|
if qb_expense:
|
|
if not hasattr(expense, "metadata") or not expense.metadata:
|
|
expense.metadata = {}
|
|
expense.metadata["quickbooks_id"] = qb_expense.get("Id")
|
|
synced_count += 1
|
|
except ValueError as e:
|
|
# Validation errors
|
|
error_msg = f"Expense {expense.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.warning(error_msg)
|
|
except requests.exceptions.HTTPError as e:
|
|
# API errors
|
|
error_msg = f"Expense {expense.id}: QuickBooks API error - {e.response.status_code}: {e.response.text[:200] if e.response else str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
except Exception as e:
|
|
# Other errors
|
|
error_msg = f"Expense {expense.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
except Exception as e:
|
|
error_msg = f"Error fetching expenses: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
|
|
try:
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
error_msg = f"Database error during sync: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.error(error_msg, exc_info=True)
|
|
return {"success": False, "message": error_msg, "synced_count": synced_count, "errors": errors}
|
|
|
|
if errors:
|
|
return {
|
|
"success": True,
|
|
"synced_count": synced_count,
|
|
"errors": errors,
|
|
"message": f"Sync completed with {len(errors)} error(s). Synced {synced_count} items."
|
|
}
|
|
|
|
return {"success": True, "synced_count": synced_count, "errors": errors, "message": f"Successfully synced {synced_count} items."}
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
error_msg = f"Network error during QuickBooks sync: {str(e)}"
|
|
logger.error(error_msg, exc_info=True)
|
|
return {"success": False, "message": error_msg}
|
|
except Exception as e:
|
|
error_msg = f"Sync failed: {str(e)}"
|
|
logger.error(error_msg, exc_info=True)
|
|
return {"success": False, "message": error_msg}
|
|
|
|
def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) -> Optional[Dict]:
|
|
"""Create invoice in QuickBooks"""
|
|
# Get customer mapping from integration config or invoice metadata
|
|
customer_mapping = self.integration.config.get("customer_mappings", {}) if self.integration else {}
|
|
item_mapping = self.integration.config.get("item_mappings", {}) if self.integration else {}
|
|
|
|
# Try to get QuickBooks customer ID from mapping or metadata
|
|
customer_qb_id = None
|
|
if invoice.client_id:
|
|
# Check mapping first
|
|
customer_qb_id = customer_mapping.get(str(invoice.client_id))
|
|
# Fallback to invoice metadata
|
|
if not customer_qb_id and hasattr(invoice, "metadata") and invoice.metadata:
|
|
customer_qb_id = invoice.metadata.get("quickbooks_customer_id")
|
|
|
|
# If no mapping found, try to find customer by name in QuickBooks
|
|
if not customer_qb_id and invoice.client_id:
|
|
try:
|
|
customer_name = invoice.client.name if invoice.client else None
|
|
if customer_name:
|
|
# Query QuickBooks for customer by DisplayName
|
|
# QuickBooks query syntax: SELECT * FROM Customer WHERE DisplayName = 'CustomerName'
|
|
# URL encode the query parameter
|
|
from urllib.parse import quote
|
|
# Escape single quotes for SQL (replace ' with '')
|
|
escaped_name = customer_name.replace("'", "''")
|
|
query = f"SELECT * FROM Customer WHERE DisplayName = '{escaped_name}'"
|
|
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
|
|
|
|
customers_response = self._api_request(
|
|
"GET",
|
|
query_url,
|
|
access_token,
|
|
realm_id
|
|
)
|
|
|
|
if customers_response and "QueryResponse" in customers_response:
|
|
customers = customers_response["QueryResponse"].get("Customer", [])
|
|
if customers:
|
|
# Handle both single customer and list of customers
|
|
if isinstance(customers, list):
|
|
if len(customers) > 0:
|
|
customer_qb_id = customers[0].get("Id")
|
|
else:
|
|
customer_qb_id = customers.get("Id")
|
|
|
|
if customer_qb_id:
|
|
# Auto-save mapping for future use
|
|
if not self.integration.config:
|
|
self.integration.config = {}
|
|
if "customer_mappings" not in self.integration.config:
|
|
self.integration.config["customer_mappings"] = {}
|
|
self.integration.config["customer_mappings"][str(invoice.client_id)] = customer_qb_id
|
|
logger.info(f"Auto-mapped client {invoice.client_id} to QuickBooks customer {customer_qb_id}")
|
|
else:
|
|
logger.warning(f"Customer '{customer_name}' not found in QuickBooks. Please configure customer mapping.")
|
|
except Exception as e:
|
|
logger.error(f"Error looking up QuickBooks customer: {e}", exc_info=True)
|
|
|
|
# If still no customer ID, we cannot create the invoice
|
|
if not customer_qb_id:
|
|
error_msg = f"Customer mapping not found for client {invoice.client_id}. Cannot create QuickBooks invoice."
|
|
logger.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
# Build QuickBooks invoice structure
|
|
qb_invoice = {
|
|
"CustomerRef": {"value": customer_qb_id},
|
|
"Line": []
|
|
}
|
|
|
|
# Add invoice items
|
|
for item in invoice.items:
|
|
try:
|
|
# Try to get QuickBooks item ID from mapping
|
|
item_qb_id = item_mapping.get(str(item.id))
|
|
if not item_qb_id and isinstance(item_mapping.get(item.description), dict):
|
|
item_qb_id = item_mapping.get(item.description, {}).get("id")
|
|
|
|
item_qb_name = item.description or "Service"
|
|
|
|
# If no mapping, try to find item by name in QuickBooks
|
|
if not item_qb_id:
|
|
try:
|
|
# Query QuickBooks for item by Name
|
|
from urllib.parse import quote
|
|
# Escape single quotes for SQL (replace ' with '')
|
|
escaped_name = item_qb_name.replace("'", "''")
|
|
query = f"SELECT * FROM Item WHERE Name = '{escaped_name}'"
|
|
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
|
|
|
|
items_response = self._api_request(
|
|
"GET",
|
|
query_url,
|
|
access_token,
|
|
realm_id
|
|
)
|
|
|
|
if items_response and "QueryResponse" in items_response:
|
|
items = items_response["QueryResponse"].get("Item", [])
|
|
if items:
|
|
# Handle both single item and list of items
|
|
if isinstance(items, list):
|
|
if len(items) > 0:
|
|
item_qb_id = items[0].get("Id")
|
|
else:
|
|
item_qb_id = items.get("Id")
|
|
|
|
if item_qb_id:
|
|
# Auto-save mapping for future use
|
|
if "item_mappings" not in self.integration.config:
|
|
self.integration.config["item_mappings"] = {}
|
|
self.integration.config["item_mappings"][str(item.id)] = item_qb_id
|
|
logger.info(f"Auto-mapped invoice item {item.id} to QuickBooks item {item_qb_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Error looking up QuickBooks item '{item_qb_name}': {e}")
|
|
|
|
# Build line item
|
|
line_item = {
|
|
"Amount": float(item.quantity * item.unit_price),
|
|
"DetailType": "SalesItemLineDetail",
|
|
"SalesItemLineDetail": {
|
|
"Qty": float(item.quantity),
|
|
"UnitPrice": float(item.unit_price),
|
|
},
|
|
}
|
|
|
|
if item_qb_id:
|
|
line_item["SalesItemLineDetail"]["ItemRef"] = {
|
|
"value": item_qb_id,
|
|
"name": item_qb_name,
|
|
}
|
|
else:
|
|
# Use description as item name (QuickBooks will use or create item)
|
|
line_item["SalesItemLineDetail"]["ItemRef"] = {
|
|
"name": item_qb_name,
|
|
}
|
|
logger.warning(f"Item mapping not found for invoice item {item.id}. Using description as item name.")
|
|
|
|
qb_invoice["Line"].append(line_item)
|
|
except Exception as e:
|
|
logger.error(f"Error processing invoice item {item.id}: {e}", exc_info=True)
|
|
# Continue with other items instead of failing completely
|
|
continue
|
|
|
|
# Validate invoice has at least one line item
|
|
if not qb_invoice["Line"]:
|
|
error_msg = "Invoice has no valid line items"
|
|
logger.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
# Add invoice date and due date
|
|
if invoice.created_at:
|
|
qb_invoice["TxnDate"] = invoice.created_at.strftime("%Y-%m-%d")
|
|
if invoice.due_date:
|
|
qb_invoice["DueDate"] = invoice.due_date.strftime("%Y-%m-%d")
|
|
|
|
endpoint = f"/v3/company/{realm_id}/invoice"
|
|
result = self._api_request("POST", endpoint, access_token, realm_id, json_data=qb_invoice)
|
|
|
|
if not result:
|
|
raise ValueError("Failed to create invoice in QuickBooks - no response from API")
|
|
|
|
# Validate response
|
|
if "Invoice" not in result:
|
|
raise ValueError(f"Invalid response from QuickBooks API: {result}")
|
|
|
|
return result
|
|
|
|
def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) -> Optional[Dict]:
|
|
"""Create expense in QuickBooks"""
|
|
# Get account mapping from integration config
|
|
account_mapping = self.integration.config.get("account_mappings", {}) if self.integration else {}
|
|
default_expense_account = self.integration.config.get("default_expense_account_id") if self.integration else None
|
|
|
|
# Try to get account ID from expense category mapping or use default
|
|
account_id = default_expense_account
|
|
if expense.category_id:
|
|
account_id = account_mapping.get(str(expense.category_id), default_expense_account)
|
|
elif hasattr(expense, "metadata") and expense.metadata:
|
|
account_id = expense.metadata.get("quickbooks_account_id", default_expense_account)
|
|
|
|
# If no account ID found, try to find or use default expense account
|
|
if not account_id:
|
|
try:
|
|
# Query for default expense accounts
|
|
from urllib.parse import quote
|
|
query = "SELECT * FROM Account WHERE AccountType = 'Expense' AND Active = true MAXRESULTS 1"
|
|
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
|
|
|
|
accounts_response = self._api_request(
|
|
"GET",
|
|
query_url,
|
|
access_token,
|
|
realm_id
|
|
)
|
|
|
|
if accounts_response and "QueryResponse" in accounts_response:
|
|
accounts = accounts_response["QueryResponse"].get("Account", [])
|
|
if accounts:
|
|
if isinstance(accounts, list):
|
|
if len(accounts) > 0:
|
|
account_id = accounts[0].get("Id")
|
|
else:
|
|
account_id = accounts.get("Id")
|
|
|
|
if account_id:
|
|
# Auto-save mapping for future use if we found an account
|
|
if expense.category_id:
|
|
if not self.integration.config:
|
|
self.integration.config = {}
|
|
if "account_mappings" not in self.integration.config:
|
|
self.integration.config["account_mappings"] = {}
|
|
self.integration.config["account_mappings"][str(expense.category_id)] = account_id
|
|
logger.info(f"Auto-mapped expense category {expense.category_id} to QuickBooks account {account_id}")
|
|
else:
|
|
# No account found - require configuration
|
|
error_msg = f"No expense account found for expense {expense.id}. Please configure account mapping or set default_expense_account_id in integration config."
|
|
logger.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
except ValueError:
|
|
# Re-raise ValueError (our own error)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error looking up QuickBooks expense account: {e}", exc_info=True)
|
|
# If we have a default, use it; otherwise fail
|
|
if default_expense_account:
|
|
account_id = default_expense_account
|
|
logger.warning(f"Using default expense account {account_id} due to lookup error")
|
|
else:
|
|
error_msg = f"Failed to determine QuickBooks account for expense {expense.id}. Please configure account mapping or default_expense_account_id."
|
|
raise ValueError(error_msg)
|
|
|
|
# Build QuickBooks expense structure
|
|
qb_expense = {
|
|
"PaymentType": "Cash",
|
|
"AccountRef": {"value": account_id},
|
|
"Line": [
|
|
{
|
|
"Amount": float(expense.amount),
|
|
"DetailType": "AccountBasedExpenseLineDetail",
|
|
"AccountBasedExpenseLineDetail": {"AccountRef": {"value": account_id}},
|
|
}
|
|
],
|
|
}
|
|
|
|
# Add vendor if available
|
|
if expense.vendor:
|
|
qb_expense["EntityRef"] = {"name": expense.vendor}
|
|
|
|
# Add expense date
|
|
if expense.date:
|
|
qb_expense["TxnDate"] = expense.date.strftime("%Y-%m-%d")
|
|
|
|
# Add memo/description
|
|
if expense.description:
|
|
qb_expense["Line"][0]["Description"] = expense.description
|
|
|
|
endpoint = f"/v3/company/{realm_id}/purchase"
|
|
result = self._api_request("POST", endpoint, access_token, realm_id, json_data=qb_expense)
|
|
|
|
if not result:
|
|
raise ValueError("Failed to create expense in QuickBooks - no response from API")
|
|
|
|
# Validate response
|
|
if "Purchase" not in result:
|
|
raise ValueError(f"Invalid response from QuickBooks API: {result}")
|
|
|
|
return result
|
|
|
|
def get_config_schema(self) -> Dict[str, Any]:
|
|
"""Get configuration schema."""
|
|
return {
|
|
"fields": [
|
|
{
|
|
"name": "realm_id",
|
|
"type": "string",
|
|
"label": "Company ID (Realm ID)",
|
|
"required": True,
|
|
"placeholder": "123456789",
|
|
"description": "QuickBooks company ID (realm ID)",
|
|
"help": "Find your company ID in QuickBooks after connecting. It's automatically set during OAuth.",
|
|
},
|
|
{
|
|
"name": "use_sandbox",
|
|
"type": "boolean",
|
|
"label": "Use Sandbox",
|
|
"default": True,
|
|
"description": "Use QuickBooks sandbox environment for testing",
|
|
},
|
|
{
|
|
"name": "sync_direction",
|
|
"type": "select",
|
|
"label": "Sync Direction",
|
|
"options": [
|
|
{"value": "quickbooks_to_timetracker", "label": "QuickBooks → TimeTracker (Import only)"},
|
|
{"value": "timetracker_to_quickbooks", "label": "TimeTracker → QuickBooks (Export only)"},
|
|
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
|
|
],
|
|
"default": "timetracker_to_quickbooks",
|
|
"description": "Choose how data flows between QuickBooks and TimeTracker",
|
|
},
|
|
{
|
|
"name": "sync_items",
|
|
"type": "array",
|
|
"label": "Items to Sync",
|
|
"options": [
|
|
{"value": "invoices", "label": "Invoices"},
|
|
{"value": "expenses", "label": "Expenses"},
|
|
{"value": "payments", "label": "Payments"},
|
|
{"value": "customers", "label": "Customers"},
|
|
],
|
|
"default": ["invoices", "expenses"],
|
|
"description": "Select which items to synchronize",
|
|
},
|
|
{"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True, "description": "Enable invoice synchronization"},
|
|
{"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True, "description": "Enable expense synchronization"},
|
|
{
|
|
"name": "auto_sync",
|
|
"type": "boolean",
|
|
"label": "Auto Sync",
|
|
"default": False,
|
|
"description": "Automatically sync when invoices or expenses are created/updated",
|
|
},
|
|
{
|
|
"name": "sync_interval",
|
|
"type": "select",
|
|
"label": "Sync Schedule",
|
|
"options": [
|
|
{"value": "manual", "label": "Manual only"},
|
|
{"value": "hourly", "label": "Every hour"},
|
|
{"value": "daily", "label": "Daily"},
|
|
],
|
|
"default": "manual",
|
|
"description": "How often to automatically sync data",
|
|
},
|
|
{
|
|
"name": "default_expense_account_id",
|
|
"type": "string",
|
|
"label": "Default Expense Account ID",
|
|
"required": False,
|
|
"default": "1",
|
|
"description": "QuickBooks account ID to use for expenses when no mapping is configured",
|
|
"help": "Find account IDs in QuickBooks Chart of Accounts",
|
|
},
|
|
{
|
|
"name": "customer_mappings",
|
|
"type": "json",
|
|
"label": "Customer Mappings",
|
|
"required": False,
|
|
"placeholder": '{"1": "qb_customer_id_123", "2": "qb_customer_id_456"}',
|
|
"description": "JSON mapping of TimeTracker client IDs to QuickBooks customer IDs",
|
|
"help": "Map your TimeTracker clients to QuickBooks customers. Format: {\"timetracker_client_id\": \"quickbooks_customer_id\"}",
|
|
},
|
|
{
|
|
"name": "item_mappings",
|
|
"type": "json",
|
|
"label": "Item Mappings",
|
|
"required": False,
|
|
"placeholder": '{"service_1": "qb_item_id_123"}',
|
|
"description": "JSON mapping of TimeTracker invoice items to QuickBooks items",
|
|
"help": "Map your TimeTracker services/products to QuickBooks items",
|
|
},
|
|
{
|
|
"name": "account_mappings",
|
|
"type": "json",
|
|
"label": "Account Mappings",
|
|
"required": False,
|
|
"placeholder": '{"expense_category_1": "qb_account_id_123"}',
|
|
"description": "JSON mapping of TimeTracker expense category IDs to QuickBooks account IDs",
|
|
"help": "Map your TimeTracker expense categories to QuickBooks accounts",
|
|
},
|
|
],
|
|
"required": ["realm_id"],
|
|
"sections": [
|
|
{
|
|
"title": "Connection Settings",
|
|
"description": "Configure your QuickBooks connection",
|
|
"fields": ["realm_id", "use_sandbox"],
|
|
},
|
|
{
|
|
"title": "Sync Settings",
|
|
"description": "Configure what and how to sync",
|
|
"fields": ["sync_direction", "sync_items", "sync_invoices", "sync_expenses", "auto_sync", "sync_interval"],
|
|
},
|
|
{
|
|
"title": "Data Mapping",
|
|
"description": "Map TimeTracker data to QuickBooks",
|
|
"fields": ["default_expense_account_id", "customer_mappings", "item_mappings", "account_mappings"],
|
|
},
|
|
],
|
|
"sync_settings": {
|
|
"enabled": True,
|
|
"auto_sync": False,
|
|
"sync_interval": "manual",
|
|
"sync_direction": "timetracker_to_quickbooks",
|
|
"sync_items": ["invoices", "expenses"],
|
|
},
|
|
}
|