Files
TimeTracker/app/integrations/xero.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

514 lines
21 KiB
Python

"""
Xero integration connector.
Sync invoices, expenses, and payments with Xero.
"""
import base64
import logging
import os
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
from app.integrations.base import BaseConnector
logger = logging.getLogger(__name__)
class XeroConnector(BaseConnector):
"""Xero integration connector."""
display_name = "Xero"
description = "Sync invoices, expenses, and payments with Xero"
icon = "xero"
BASE_URL = "https://api.xero.com"
@property
def provider_name(self) -> str:
return "xero"
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
"""Get Xero OAuth authorization URL."""
from app.models import Settings
settings = Settings.get_settings()
creds = settings.get_integration_credentials("xero")
client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID")
if not client_id:
raise ValueError("XERO_CLIENT_ID not configured")
scopes = [
"accounting.invoices",
"accounting.payments",
"accounting.contacts",
"accounting.settings",
"offline_access",
]
auth_url = "https://login.xero.com/identity/connect/authorize"
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"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("xero")
client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID")
client_secret = creds.get("client_secret") or os.getenv("XERO_CLIENT_SECRET")
if not client_id or not client_secret:
raise ValueError("Xero OAuth credentials not configured")
token_url = "https://identity.xero.com/connect/token"
# Xero 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}", "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 tenant info
tenant_info = {}
if "access_token" in data:
try:
tenants_response = requests.get(
f"{self.BASE_URL}/connections", headers={"Authorization": f"Bearer {data['access_token']}"}
)
if tenants_response.status_code == 200:
tenants = tenants_response.json()
if tenants:
tenant_info = {
"tenantId": tenants[0].get("tenantId"),
"tenantName": tenants[0].get("tenantName"),
}
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": data.get("token_type", "Bearer"),
"scope": data.get("scope"),
"extra_data": tenant_info,
}
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("xero")
client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID")
client_secret = creds.get("client_secret") or os.getenv("XERO_CLIENT_SECRET")
token_url = "https://identity.xero.com/connect/token"
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}", "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
from app.utils.db import safe_commit
safe_commit("refresh_xero_token", {"integration_id": self.integration.id})
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 Xero."""
try:
tenant_id = self.integration.config.get("tenant_id") if self.integration else None
if not tenant_id:
# Try to get from extra_data
if self.credentials and self.credentials.extra_data:
tenant_id = self.credentials.extra_data.get("tenantId")
if not tenant_id:
return {"success": False, "message": "Xero tenant not configured"}
organisation_info = self._api_request(
"GET", f"/api.xro/2.0/Organisation", self.get_access_token(), tenant_id
)
if organisation_info:
org_name = organisation_info.get("Organisations", [{}])[0].get("Name", "Unknown")
return {"success": True, "message": f"Connected to Xero organisation: {org_name}"}
else:
return {"success": False, "message": "Failed to retrieve organisation 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,
tenant_id: str,
json_body: Optional[Dict] = None,
) -> Optional[Dict]:
"""Make API request to Xero"""
url = f"{self.BASE_URL}{endpoint}"
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json",
"Xero-tenant-id": tenant_id,
}
try:
if method.upper() == "GET":
response = requests.get(url, headers=headers, timeout=10)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, timeout=10, json=json_body or {})
else:
response = requests.request(method, url, headers=headers, timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Xero API request failed: {e}")
return None
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
"""Sync invoices and expenses with Xero"""
from app import db
from app.models import Expense, Invoice
try:
tenant_id = self.integration.config.get("tenant_id")
if not tenant_id:
if self.credentials and self.credentials.extra_data:
tenant_id = self.credentials.extra_data.get("tenantId")
if not tenant_id:
return {"success": False, "message": "Xero tenant not configured"}
access_token = self.get_access_token()
synced_count = 0
errors = []
# Sync invoices (create as invoices in Xero)
if sync_type == "full" or sync_type == "invoices":
invoices = Invoice.query.filter(
Invoice.status.in_(["sent", "paid"]), Invoice.created_at >= datetime.utcnow() - timedelta(days=90)
).all()
for invoice in invoices:
try:
xero_invoice = self._create_xero_invoice(invoice, access_token, tenant_id)
if xero_invoice:
# Store Xero ID in invoice metadata
if not hasattr(invoice, "metadata") or not invoice.metadata:
invoice.metadata = {}
invoice.metadata["xero_invoice_id"] = xero_invoice.get("Invoices", [{}])[0].get("InvoiceID")
synced_count += 1
except Exception as e:
errors.append(f"Error syncing invoice {invoice.id}: {str(e)}")
# Sync expenses (create as expenses in Xero)
if sync_type == "full" or sync_type == "expenses":
expenses = Expense.query.filter(
Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90)
).all()
for expense in expenses:
try:
xero_expense = self._create_xero_expense(expense, access_token, tenant_id)
if xero_expense:
if not hasattr(expense, "metadata") or not expense.metadata:
expense.metadata = {}
expense.metadata["xero_expense_id"] = xero_expense.get("ExpenseClaims", [{}])[0].get(
"ExpenseClaimID"
)
synced_count += 1
except Exception as e:
errors.append(f"Error syncing expense {expense.id}: {str(e)}")
db.session.commit()
return {"success": True, "synced_count": synced_count, "errors": errors}
except Exception as e:
return {"success": False, "message": f"Sync failed: {str(e)}"}
def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Optional[Dict]:
"""Create invoice in Xero"""
# Get customer mapping from integration config or invoice metadata
contact_mapping = self.integration.config.get("contact_mappings", {}) if self.integration else {}
item_mapping = self.integration.config.get("item_mappings", {}) if self.integration else {}
# Try to get Xero contact ID from mapping or metadata
contact_id = None
contact_name = invoice.client.name if invoice.client else "Unknown"
if invoice.client_id:
# Check mapping first
contact_id = contact_mapping.get(str(invoice.client_id))
# Fallback to invoice metadata
if not contact_id and hasattr(invoice, "metadata") and invoice.metadata:
contact_id = invoice.metadata.get("xero_contact_id")
# Build Xero invoice structure
xero_invoice = {
"Type": "ACCREC",
"Date": invoice.date.strftime("%Y-%m-%d") if invoice.date else datetime.utcnow().strftime("%Y-%m-%d"),
"DueDate": (
invoice.due_date.strftime("%Y-%m-%d") if invoice.due_date else datetime.utcnow().strftime("%Y-%m-%d")
),
"LineItems": [],
}
# Add contact - use ID if available, otherwise use name
if contact_id:
xero_invoice["Contact"] = {"ContactID": contact_id}
else:
xero_invoice["Contact"] = {"Name": contact_name}
logger.warning(f"Contact mapping not found for client {invoice.client_id}. Using name: {contact_name}")
# Add invoice items
for item in invoice.items:
# Try to get Xero item code from mapping
item_code = item_mapping.get(str(item.id)) or item_mapping.get(item.description, {}).get("code")
line_item = {
"Description": item.description,
"Quantity": float(item.quantity),
"UnitAmount": float(item.unit_price),
"LineAmount": float(item.quantity * item.unit_price),
}
# Add item code if available
if item_code:
line_item["ItemCode"] = item_code
xero_invoice["LineItems"].append(line_item)
endpoint = "/api.xro/2.0/Invoices"
return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"Invoices": [xero_invoice]})
def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Optional[Dict]:
"""Create expense in Xero"""
# 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_code", "200") if self.integration else "200"
)
# Try to get account code from expense category mapping or use default
account_code = default_expense_account
if expense.category_id:
account_code = account_mapping.get(str(expense.category_id), default_expense_account)
elif hasattr(expense, "metadata") and expense.metadata:
account_code = expense.metadata.get("xero_account_code", default_expense_account)
# Build Xero expense structure
xero_expense = {
"Date": expense.date.strftime("%Y-%m-%d") if expense.date else datetime.utcnow().strftime("%Y-%m-%d"),
"Contact": {"Name": expense.vendor or "Unknown"},
"LineItems": [
{
"Description": expense.description or "Expense",
"Quantity": 1.0,
"UnitAmount": float(expense.amount),
"LineAmount": float(expense.amount),
"AccountCode": account_code,
}
],
}
endpoint = "/api.xro/2.0/ExpenseClaims"
return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"ExpenseClaims": [xero_expense]})
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
return {
"fields": [
{
"name": "tenant_id",
"type": "string",
"label": "Tenant ID",
"required": True,
"placeholder": "tenant-uuid-123",
"description": "Xero organisation tenant ID",
"help": "Find your tenant ID in Xero after connecting. It's automatically set during OAuth.",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "xero_to_timetracker", "label": "Xero → TimeTracker (Import only)"},
{"value": "timetracker_to_xero", "label": "TimeTracker → Xero (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "timetracker_to_xero",
"description": "Choose how data flows between Xero 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": "contacts", "label": "Contacts"},
],
"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_code",
"type": "string",
"label": "Default Expense Account Code",
"required": False,
"default": "200",
"description": "Xero account code to use for expenses when no mapping is configured",
"help": "Find account codes in Xero Chart of Accounts",
},
{
"name": "contact_mappings",
"type": "json",
"label": "Contact Mappings",
"required": False,
"placeholder": '{"1": "contact-uuid-123", "2": "contact-uuid-456"}',
"description": "JSON mapping of TimeTracker client IDs to Xero Contact IDs",
"help": 'Map your TimeTracker clients to Xero contacts. Format: {"timetracker_client_id": "xero_contact_id"}',
},
{
"name": "item_mappings",
"type": "json",
"label": "Item Mappings",
"required": False,
"placeholder": '{"service_1": "item_code_123"}',
"description": "JSON mapping of TimeTracker invoice items to Xero item codes",
"help": "Map your TimeTracker services/products to Xero items",
},
{
"name": "account_mappings",
"type": "json",
"label": "Account Mappings",
"required": False,
"placeholder": '{"expense_category_1": "account_code_200"}',
"description": "JSON mapping of TimeTracker expense category IDs to Xero account codes",
"help": "Map your TimeTracker expense categories to Xero accounts",
},
],
"required": ["tenant_id"],
"sections": [
{
"title": "Connection Settings",
"description": "Configure your Xero connection",
"fields": ["tenant_id"],
},
{
"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 Xero",
"fields": ["default_expense_account_code", "contact_mappings", "item_mappings", "account_mappings"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "timetracker_to_xero",
"sync_items": ["invoices", "expenses"],
},
}