Synced files: - Session logs updated - Latest context and credentials - Command/directive updates Machine: Mikes-MacBook-Air.local Timestamp: 2026-03-09 08:14:13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
446 lines
14 KiB
Python
446 lines
14 KiB
Python
"""
|
|
SyncroRMM integration service for Quote Wizard.
|
|
|
|
This module handles all interactions with the SyncroRMM API for lead creation
|
|
and customer duplicate detection when quotes are submitted.
|
|
|
|
API Documentation: https://api-docs.syncromsp.com/
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
import httpx
|
|
|
|
if TYPE_CHECKING:
|
|
from api.models.quote import Quote
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# TODO: Move to environment variables or secure configuration for production
|
|
SYNCRO_API_BASE_URL = "https://computerguru.syncromsp.com/api/v1"
|
|
SYNCRO_API_KEY = "T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3"
|
|
|
|
# HTTP client configuration
|
|
SYNCRO_TIMEOUT_SECONDS = 30.0
|
|
SYNCRO_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
|
|
|
|
@dataclass
|
|
class CustomerCheckResult:
|
|
"""Result of checking for an existing customer in Syncro."""
|
|
exists: bool
|
|
customer_id: Optional[str] = None
|
|
customer_name: Optional[str] = None
|
|
match_type: Optional[str] = None # 'email' or 'business_name'
|
|
|
|
|
|
@dataclass
|
|
class LeadCreationResult:
|
|
"""Result of creating a lead in Syncro."""
|
|
success: bool
|
|
lead_id: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
class SyncroService:
|
|
"""
|
|
Service for interacting with the SyncroRMM API.
|
|
|
|
Handles customer duplicate checking and lead creation for the Quote Wizard.
|
|
All API calls are made asynchronously to avoid blocking quote submission.
|
|
|
|
Example:
|
|
```python
|
|
syncro = SyncroService()
|
|
|
|
# Check for existing customer
|
|
result = await syncro.check_existing_customer(
|
|
email="contact@company.com",
|
|
business_name="Company Inc"
|
|
)
|
|
if result.exists:
|
|
print(f"Customer exists: {result.customer_name}")
|
|
|
|
# Create lead from quote
|
|
lead_result = await syncro.create_lead(quote)
|
|
if lead_result.success:
|
|
print(f"Lead created: {lead_result.lead_id}")
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_base_url: str = SYNCRO_API_BASE_URL,
|
|
api_key: str = SYNCRO_API_KEY,
|
|
timeout: float = SYNCRO_TIMEOUT_SECONDS,
|
|
connect_timeout: float = SYNCRO_CONNECT_TIMEOUT_SECONDS
|
|
):
|
|
"""
|
|
Initialize the SyncroService.
|
|
|
|
Args:
|
|
api_base_url: Base URL for the Syncro API
|
|
api_key: API key for authentication
|
|
timeout: Total request timeout in seconds
|
|
connect_timeout: Connection timeout in seconds
|
|
"""
|
|
self.api_base_url = api_base_url.rstrip('/')
|
|
self.api_key = api_key
|
|
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
|
|
|
def _get_client(self) -> httpx.AsyncClient:
|
|
"""
|
|
Create an async HTTP client with configured settings.
|
|
|
|
Returns:
|
|
httpx.AsyncClient: Configured HTTP client
|
|
"""
|
|
return httpx.AsyncClient(
|
|
timeout=self.timeout,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json"
|
|
}
|
|
)
|
|
|
|
def _build_url(self, endpoint: str, **params) -> str:
|
|
"""
|
|
Build a full API URL with the api_key parameter.
|
|
|
|
Args:
|
|
endpoint: API endpoint path (e.g., '/customers')
|
|
**params: Additional query parameters
|
|
|
|
Returns:
|
|
str: Full URL with query parameters
|
|
"""
|
|
url = f"{self.api_base_url}{endpoint}"
|
|
query_params = {"api_key": self.api_key, **params}
|
|
|
|
# Build query string
|
|
query_string = "&".join(
|
|
f"{key}={httpx.URL('').copy_with(params={key: str(value)}).params[key]}"
|
|
for key, value in query_params.items()
|
|
if value is not None
|
|
)
|
|
|
|
return f"{url}?{query_string}"
|
|
|
|
async def check_existing_customer(
|
|
self,
|
|
email: str,
|
|
business_name: Optional[str] = None
|
|
) -> CustomerCheckResult:
|
|
"""
|
|
Check if a customer already exists in Syncro.
|
|
|
|
Performs a two-stage check:
|
|
1. Search by email address (primary)
|
|
2. Search by business name if no email match (secondary)
|
|
|
|
Args:
|
|
email: Contact email address to search for
|
|
business_name: Optional business name for secondary search
|
|
|
|
Returns:
|
|
CustomerCheckResult: Object containing match status and details
|
|
|
|
Note:
|
|
This method handles errors gracefully and returns a non-match
|
|
result if the API is unavailable, to avoid blocking quote submission.
|
|
"""
|
|
async with self._get_client() as client:
|
|
# First, check by email
|
|
try:
|
|
email_result = await self._search_customers_by_email(client, email)
|
|
if email_result.exists:
|
|
return email_result
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Syncro email search failed for {email}: {e}",
|
|
exc_info=True
|
|
)
|
|
# Continue to business name search if email search fails
|
|
|
|
# If no email match, try business name
|
|
if business_name:
|
|
try:
|
|
name_result = await self._search_customers_by_business_name(
|
|
client, business_name
|
|
)
|
|
if name_result.exists:
|
|
return name_result
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Syncro business name search failed for {business_name}: {e}",
|
|
exc_info=True
|
|
)
|
|
|
|
# No matches found
|
|
return CustomerCheckResult(exists=False)
|
|
|
|
async def _search_customers_by_email(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
email: str
|
|
) -> CustomerCheckResult:
|
|
"""
|
|
Search for customers by email address.
|
|
|
|
Args:
|
|
client: HTTP client instance
|
|
email: Email address to search for
|
|
|
|
Returns:
|
|
CustomerCheckResult: Match result
|
|
|
|
Raises:
|
|
httpx.HTTPError: If the API request fails
|
|
"""
|
|
url = self._build_url("/customers", email=email)
|
|
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
customers = data.get("customers", [])
|
|
|
|
if customers:
|
|
customer = customers[0]
|
|
return CustomerCheckResult(
|
|
exists=True,
|
|
customer_id=str(customer.get("id")),
|
|
customer_name=customer.get("business_name") or customer.get("fullname"),
|
|
match_type="email"
|
|
)
|
|
|
|
return CustomerCheckResult(exists=False)
|
|
|
|
async def _search_customers_by_business_name(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
business_name: str
|
|
) -> CustomerCheckResult:
|
|
"""
|
|
Search for customers by business name.
|
|
|
|
Args:
|
|
client: HTTP client instance
|
|
business_name: Business name to search for
|
|
|
|
Returns:
|
|
CustomerCheckResult: Match result
|
|
|
|
Raises:
|
|
httpx.HTTPError: If the API request fails
|
|
"""
|
|
url = self._build_url("/customers", business_name=business_name)
|
|
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
customers = data.get("customers", [])
|
|
|
|
if customers:
|
|
# Look for exact match or very close match
|
|
normalized_search = business_name.lower().strip()
|
|
for customer in customers:
|
|
customer_name = customer.get("business_name", "").lower().strip()
|
|
if customer_name == normalized_search:
|
|
return CustomerCheckResult(
|
|
exists=True,
|
|
customer_id=str(customer.get("id")),
|
|
customer_name=customer.get("business_name"),
|
|
match_type="business_name"
|
|
)
|
|
|
|
return CustomerCheckResult(exists=False)
|
|
|
|
async def create_lead(self, quote: "Quote") -> LeadCreationResult:
|
|
"""
|
|
Create a lead in Syncro from a submitted quote.
|
|
|
|
Builds a formatted lead with quote details in the notes field.
|
|
|
|
Args:
|
|
quote: Quote object with contact info and items
|
|
|
|
Returns:
|
|
LeadCreationResult: Object containing success status and lead ID
|
|
|
|
Note:
|
|
This method handles errors gracefully to avoid blocking quote
|
|
submission. Errors are logged but not raised.
|
|
"""
|
|
if not quote.contact_email:
|
|
return LeadCreationResult(
|
|
success=False,
|
|
error="Quote has no contact email"
|
|
)
|
|
|
|
# Parse contact name into first/last
|
|
first_name, last_name = self._parse_contact_name(quote.contact_name or "")
|
|
|
|
# Build formatted notes with quote summary
|
|
notes = self._build_quote_summary(quote)
|
|
|
|
lead_data = {
|
|
"business_name": quote.company_name or "",
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"email": quote.contact_email,
|
|
"phone": quote.contact_phone or "",
|
|
"address": "",
|
|
"referred_by": "Website Quote Tool",
|
|
"status": "New",
|
|
"notes": notes
|
|
}
|
|
|
|
try:
|
|
async with self._get_client() as client:
|
|
url = self._build_url("/leads")
|
|
|
|
response = await client.post(url, json=lead_data)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
lead_id = str(data.get("lead", {}).get("id", ""))
|
|
|
|
if lead_id:
|
|
logger.info(
|
|
f"Created Syncro lead {lead_id} for quote {quote.id}"
|
|
)
|
|
return LeadCreationResult(success=True, lead_id=lead_id)
|
|
else:
|
|
logger.warning(
|
|
f"Syncro lead creation returned no ID for quote {quote.id}"
|
|
)
|
|
return LeadCreationResult(
|
|
success=False,
|
|
error="No lead ID returned from Syncro"
|
|
)
|
|
|
|
except httpx.TimeoutException as e:
|
|
error_msg = f"Syncro API timeout: {e}"
|
|
logger.error(f"{error_msg} for quote {quote.id}")
|
|
return LeadCreationResult(success=False, error=error_msg)
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
error_msg = f"Syncro API error {e.response.status_code}: {e.response.text}"
|
|
logger.error(f"{error_msg} for quote {quote.id}")
|
|
return LeadCreationResult(success=False, error=error_msg)
|
|
|
|
except Exception as e:
|
|
error_msg = f"Unexpected error: {str(e)}"
|
|
logger.error(f"{error_msg} for quote {quote.id}", exc_info=True)
|
|
return LeadCreationResult(success=False, error=error_msg)
|
|
|
|
def _parse_contact_name(self, full_name: str) -> tuple[str, str]:
|
|
"""
|
|
Parse a full name into first and last name components.
|
|
|
|
Args:
|
|
full_name: Full contact name
|
|
|
|
Returns:
|
|
tuple: (first_name, last_name)
|
|
"""
|
|
parts = full_name.strip().split(maxsplit=1)
|
|
|
|
if len(parts) == 0:
|
|
return ("", "")
|
|
elif len(parts) == 1:
|
|
return (parts[0], "")
|
|
else:
|
|
return (parts[0], parts[1])
|
|
|
|
def _build_quote_summary(self, quote: "Quote") -> str:
|
|
"""
|
|
Build formatted notes from quote items for Syncro lead.
|
|
|
|
Creates a human-readable summary of the quote including:
|
|
- Quote reference number
|
|
- Monthly and setup totals
|
|
- List of selected services with pricing
|
|
- Customer notes if provided
|
|
|
|
Args:
|
|
quote: Quote object with items
|
|
|
|
Returns:
|
|
str: Formatted notes string for Syncro lead
|
|
"""
|
|
lines = []
|
|
|
|
# Quote reference
|
|
access_token_short = quote.access_token[:8] if quote.access_token else "N/A"
|
|
lines.append(f"Quote #{access_token_short}")
|
|
lines.append("")
|
|
|
|
# Totals
|
|
monthly_total = quote.monthly_total or Decimal("0.00")
|
|
setup_total = quote.setup_total or Decimal("0.00")
|
|
|
|
lines.append(f"Monthly: ${monthly_total:,.2f}")
|
|
if setup_total > 0:
|
|
lines.append(f"Setup: ${setup_total:,.2f}")
|
|
lines.append("")
|
|
|
|
# Services
|
|
lines.append("Services:")
|
|
|
|
if hasattr(quote, 'items') and quote.items:
|
|
for item in sorted(quote.items, key=lambda x: x.sort_order or 0):
|
|
quantity = item.quantity or 1
|
|
unit_price = item.unit_price or Decimal("0.00")
|
|
line_total = quantity * unit_price
|
|
|
|
if quantity > 1:
|
|
lines.append(
|
|
f"- {item.service_name} ({quantity} x ${unit_price:,.2f}): "
|
|
f"${line_total:,.2f}/mo"
|
|
)
|
|
else:
|
|
lines.append(f"- {item.service_name}: ${unit_price:,.2f}/mo")
|
|
|
|
# Add setup fee if present
|
|
if item.setup_fee and item.setup_fee > 0:
|
|
lines.append(f" Setup: ${item.setup_fee:,.2f}")
|
|
else:
|
|
lines.append("- No items")
|
|
|
|
# Employee count
|
|
if quote.employee_count:
|
|
lines.append("")
|
|
lines.append(f"Employees/Users: {quote.employee_count}")
|
|
|
|
# Customer notes
|
|
if quote.notes:
|
|
lines.append("")
|
|
lines.append("Customer Notes:")
|
|
lines.append(quote.notes)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# Singleton instance for convenience
|
|
_syncro_service: Optional[SyncroService] = None
|
|
|
|
|
|
def get_syncro_service() -> SyncroService:
|
|
"""
|
|
Get or create the singleton SyncroService instance.
|
|
|
|
Returns:
|
|
SyncroService: The service instance
|
|
"""
|
|
global _syncro_service
|
|
if _syncro_service is None:
|
|
_syncro_service = SyncroService()
|
|
return _syncro_service
|