Files
TimeTracker/app/integrations/quickbooks.py
T
Dries Peeters 3218ab012a feat: expand client portal and approval workflows
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.
2026-01-02 07:52:32 +01:00

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"],
},
}