Files
claudetools/api/services/syncro_service.py
Mike Swanson fa15b03180 sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:59:08 -07:00

448 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
import os
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__)
SYNCRO_API_BASE_URL = os.environ.get(
"SYNCRO_API_BASE_URL", "https://computerguru.syncromsp.com/api/v1"
)
SYNCRO_API_KEY = os.environ.get("SYNCRO_API_KEY", "")
# 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