""" 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