mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 05:10:26 -05:00
b4486a627f
- 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
514 lines
21 KiB
Python
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"],
|
|
},
|
|
}
|