sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13

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>
This commit is contained in:
2026-03-09 08:14:13 -07:00
parent f81872784b
commit a1a19f8c00
59 changed files with 14435 additions and 1 deletions

View File

@@ -11,6 +11,8 @@ from . import (
credential_service,
credential_audit_log_service,
security_incident_service,
quote_service,
syncro_service,
)
__all__ = [
@@ -24,4 +26,6 @@ __all__ = [
"credential_service",
"credential_audit_log_service",
"security_incident_service",
"quote_service",
"syncro_service",
]

View File

@@ -0,0 +1,985 @@
"""
Quote service layer for business logic and database operations.
This module handles all database operations for quotes, providing a clean
separation between the API routes and data access layer.
"""
import json
import logging
import secrets
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from api.models.quote import (
Quote,
QuoteActivity,
QuoteItem,
QuoteNotification,
QuoteStatus,
BillingFrequency,
)
from api.schemas.quote import (
QuoteCreate,
QuoteUpdate,
QuoteSubmit,
QuoteItemCreate,
QuoteAdminUpdate,
QuoteListItem,
QuoteStatsResponse,
)
from api.services.syncro_service import get_syncro_service
logger = logging.getLogger(__name__)
def generate_access_token() -> str:
"""
Generate a secure, URL-safe access token for quote access.
Returns:
str: A 43-character URL-safe token
"""
return secrets.token_urlsafe(32)
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
"""
Calculate monthly, setup, and annual totals from quote items.
Args:
items: List of QuoteItem objects
Returns:
tuple: (monthly_total, setup_total, annual_total)
"""
monthly_total = Decimal("0.00")
setup_total = Decimal("0.00")
for item in items:
# Calculate line total
line_total = item.unit_price * item.quantity
# Add to appropriate total based on billing frequency
if item.billing_frequency == BillingFrequency.MONTHLY.value:
monthly_total += line_total
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
monthly_total += line_total / Decimal("3")
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
monthly_total += line_total / Decimal("12")
# one_time items don't add to monthly
# Setup fees are always one-time
setup_total += item.setup_fee
# Annual total is monthly * 12 + setup
annual_total = (monthly_total * Decimal("12")) + setup_total
return monthly_total, setup_total, annual_total
def log_activity(
db: Session,
quote_id: str,
action: str,
description: Optional[str] = None,
actor: Optional[str] = None,
ip_address: Optional[str] = None,
metadata: Optional[dict] = None
) -> QuoteActivity:
"""
Log an activity for a quote.
Args:
db: Database session
quote_id: UUID of the quote
action: Action being performed
description: Detailed description
actor: Who performed the action
ip_address: IP address of the actor
metadata: Additional metadata as dict
Returns:
QuoteActivity: The created activity record
"""
activity = QuoteActivity(
quote_id=quote_id,
action=action,
description=description,
actor=actor,
ip_address=ip_address,
metadata=json.dumps(metadata) if metadata else None
)
db.add(activity)
db.flush()
return activity
def create_quote(
db: Session,
quote_data: QuoteCreate,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> Quote:
"""
Create a new quote draft with access token.
Args:
db: Database session
quote_data: Quote creation data
ip_address: IP address of the requester
user_agent: Browser user agent
Returns:
Quote: The created quote object
Example:
```python
quote_data = QuoteCreate(employee_count=25)
quote = create_quote(db, quote_data, ip_address="192.168.1.1")
print(f"Quote created: {quote.access_token}")
```
"""
try:
# Create quote with unique access token
quote = Quote(
access_token=generate_access_token(),
status=QuoteStatus.DRAFT.value,
employee_count=quote_data.employee_count,
notes=quote_data.notes,
ip_address=ip_address,
user_agent=user_agent,
# Set expiration to 30 days from now
expires_at=datetime.utcnow() + timedelta(days=30)
)
db.add(quote)
db.flush() # Get the quote ID
# Add initial items if provided
if quote_data.items:
for idx, item_data in enumerate(quote_data.items):
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else idx
)
db.add(item)
db.flush()
# Calculate and update totals
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="created",
description="Quote draft created",
ip_address=ip_address,
metadata={"employee_count": quote_data.employee_count}
)
db.commit()
db.refresh(quote)
return quote
except IntegrityError as e:
db.rollback()
# If token collision (extremely rare), retry once
if "access_token" in str(e.orig):
return create_quote(db, quote_data, ip_address, user_agent)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Database error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create quote: {str(e)}"
)
def get_quote_by_token(db: Session, access_token: str) -> Quote:
"""
Retrieve a quote by its access token (public access).
Args:
db: Database session
access_token: The quote's access token
Returns:
Quote: The quote object with items loaded
Raises:
HTTPException: 404 if quote not found
"""
quote = (
db.query(Quote)
.options(joinedload(Quote.items))
.filter(Quote.access_token == access_token)
.first()
)
if not quote:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quote not found"
)
# Check if expired
if quote.expires_at and quote.expires_at < datetime.utcnow():
if quote.status == QuoteStatus.DRAFT.value:
quote.status = QuoteStatus.EXPIRED.value
db.commit()
return quote
def get_quote_by_id(db: Session, quote_id: UUID) -> Quote:
"""
Retrieve a quote by its ID (admin access).
Args:
db: Database session
quote_id: UUID of the quote
Returns:
Quote: The quote object with all related data loaded
Raises:
HTTPException: 404 if quote not found
"""
quote = (
db.query(Quote)
.options(
joinedload(Quote.items),
joinedload(Quote.activities),
joinedload(Quote.notifications)
)
.filter(Quote.id == str(quote_id))
.first()
)
if not quote:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Quote with ID {quote_id} not found"
)
return quote
def update_quote(
db: Session,
access_token: str,
quote_data: QuoteUpdate,
ip_address: Optional[str] = None
) -> Quote:
"""
Update a quote (add/remove items, update details).
Only drafts can be updated. Replaces all items if items are provided.
Args:
db: Database session
access_token: The quote's access token
quote_data: Update data
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
Raises:
HTTPException: 404 if not found, 400 if not a draft
"""
quote = get_quote_by_token(db, access_token)
# Only drafts can be updated
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update quote with status '{quote.status}'. Only drafts can be modified."
)
try:
# Update basic fields if provided
update_data = quote_data.model_dump(exclude_unset=True, exclude={"items"})
changes = []
for field, value in update_data.items():
old_value = getattr(quote, field)
if old_value != value:
setattr(quote, field, value)
changes.append(f"{field}: {old_value} -> {value}")
# Replace items if provided
if quote_data.items is not None:
# Remove existing items
db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id).delete()
# Add new items
for idx, item_data in enumerate(quote_data.items):
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else idx
)
db.add(item)
changes.append(f"items: replaced with {len(quote_data.items)} items")
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
if changes:
log_activity(
db=db,
quote_id=quote.id,
action="updated",
description=f"Quote updated: {', '.join(changes)}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update quote: {str(e)}"
)
def submit_quote(
db: Session,
access_token: str,
submit_data: QuoteSubmit,
ip_address: Optional[str] = None
) -> Quote:
"""
Submit a quote with contact information.
Transitions quote from draft to submitted status.
Args:
db: Database session
access_token: The quote's access token
submit_data: Submission data with required contact info
ip_address: IP address of the requester
Returns:
Quote: The submitted quote object
Raises:
HTTPException: 404 if not found, 400 if not a draft or no items
"""
quote = get_quote_by_token(db, access_token)
# Only drafts can be submitted
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot submit quote with status '{quote.status}'. Only drafts can be submitted."
)
# Must have at least one item
if not quote.items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit quote without any items. Please add at least one service."
)
try:
# Update contact information
quote.company_name = submit_data.company_name
quote.contact_name = submit_data.contact_name
quote.contact_email = submit_data.contact_email
quote.contact_phone = submit_data.contact_phone
if submit_data.notes:
quote.notes = submit_data.notes
# Update status and timestamp
quote.status = QuoteStatus.SUBMITTED.value
quote.submitted_at = datetime.utcnow()
# Extend expiration to 90 days from submission
quote.expires_at = datetime.utcnow() + timedelta(days=90)
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="submitted",
description=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email})",
actor=submit_data.contact_email,
ip_address=ip_address,
metadata={
"company_name": submit_data.company_name,
"contact_email": submit_data.contact_email,
"monthly_total": str(quote.monthly_total),
"setup_total": str(quote.setup_total)
}
)
# Create admin notification record (actual sending would be handled elsewhere)
notification = QuoteNotification(
quote_id=quote.id,
notification_type="admin_alert",
recipient="admin@example.com", # Would come from config in production
subject=f"New Quote Submission: {submit_data.company_name}",
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
status="pending"
)
db.add(notification)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to submit quote: {str(e)}"
)
async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
"""
Sync a submitted quote to SyncroRMM.
Checks for existing customer and creates a lead in Syncro. Updates the
quote with sync status and existing customer flag.
This function is designed to be called after submit_quote() completes,
typically as a background task or in the API endpoint. It handles all
Syncro API errors gracefully to avoid blocking the quote submission.
Args:
db: Database session
quote: The submitted quote object (must have contact_email)
Returns:
dict: Sync result with keys:
- synced: bool - Whether lead was created successfully
- is_existing_customer: bool - Whether customer already exists
- syncro_lead_id: str|None - Lead ID if created
- error: str|None - Error message if sync failed
Example:
```python
quote = submit_quote(db, access_token, submit_data, ip_address)
sync_result = await sync_quote_to_syncro(db, quote)
if sync_result["synced"]:
print(f"Lead created: {sync_result['syncro_lead_id']}")
```
"""
result = {
"synced": False,
"is_existing_customer": False,
"syncro_lead_id": None,
"error": None
}
if not quote.contact_email:
result["error"] = "Quote has no contact email"
return result
try:
syncro = get_syncro_service()
# Check for existing customer
customer_check = await syncro.check_existing_customer(
email=quote.contact_email,
business_name=quote.company_name
)
if customer_check.exists:
quote.is_existing_customer = True
result["is_existing_customer"] = True
logger.info(
f"Quote {quote.id} is from existing customer: "
f"{customer_check.customer_name} (ID: {customer_check.customer_id}, "
f"match: {customer_check.match_type})"
)
# Log activity for existing customer
log_activity(
db=db,
quote_id=quote.id,
action="syncro_customer_found",
description=f"Existing Syncro customer found: {customer_check.customer_name}",
metadata={
"syncro_customer_id": customer_check.customer_id,
"match_type": customer_check.match_type
}
)
# Create lead in Syncro
lead_result = await syncro.create_lead(quote)
if lead_result.success:
quote.syncro_lead_id = lead_result.lead_id
quote.syncro_synced_at = datetime.utcnow()
result["synced"] = True
result["syncro_lead_id"] = lead_result.lead_id
# Log activity for successful sync
log_activity(
db=db,
quote_id=quote.id,
action="syncro_lead_created",
description=f"Lead created in Syncro: {lead_result.lead_id}",
metadata={
"syncro_lead_id": lead_result.lead_id,
"is_existing_customer": customer_check.exists
}
)
else:
result["error"] = lead_result.error
logger.warning(
f"Failed to create Syncro lead for quote {quote.id}: {lead_result.error}"
)
# Log activity for failed sync
log_activity(
db=db,
quote_id=quote.id,
action="syncro_sync_failed",
description=f"Failed to sync to Syncro: {lead_result.error}",
metadata={"error": lead_result.error}
)
# Commit the updates to quote
db.commit()
db.refresh(quote)
except Exception as e:
# Log error but don't fail the overall operation
error_msg = str(e)
result["error"] = error_msg
logger.error(
f"Unexpected error syncing quote {quote.id} to Syncro: {error_msg}",
exc_info=True
)
try:
log_activity(
db=db,
quote_id=quote.id,
action="syncro_sync_error",
description=f"Syncro sync error: {error_msg}",
metadata={"error": error_msg}
)
db.commit()
except Exception:
db.rollback()
return result
def list_quotes(
db: Session,
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
search: Optional[str] = None
) -> tuple[list[Quote], int]:
"""
List quotes with pagination and optional filters (admin).
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
status_filter: Filter by status
search: Search in company_name, contact_name, contact_email
Returns:
tuple: (list of quotes, total count)
"""
query = db.query(Quote).options(joinedload(Quote.items))
# Apply filters
if status_filter:
query = query.filter(Quote.status == status_filter)
if search:
search_term = f"%{search}%"
query = query.filter(
(Quote.company_name.ilike(search_term)) |
(Quote.contact_name.ilike(search_term)) |
(Quote.contact_email.ilike(search_term))
)
# Get total count before pagination
total = query.count()
# Apply pagination and ordering
quotes = (
query
.order_by(Quote.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return quotes, total
def update_quote_status(
db: Session,
quote_id: UUID,
update_data: QuoteAdminUpdate,
admin_user: str
) -> Quote:
"""
Update quote status and admin notes (admin).
Args:
db: Database session
quote_id: UUID of the quote
update_data: Admin update data
admin_user: Username of the admin making the change
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_id(db, quote_id)
try:
changes = []
if update_data.status is not None and update_data.status.value != quote.status:
old_status = quote.status
quote.status = update_data.status.value
changes.append(f"status: {old_status} -> {update_data.status.value}")
if update_data.admin_notes is not None:
quote.admin_notes = update_data.admin_notes
changes.append("admin_notes updated")
if update_data.expires_at is not None:
quote.expires_at = update_data.expires_at
changes.append(f"expires_at: {update_data.expires_at}")
# Log activity
if changes:
log_activity(
db=db,
quote_id=quote.id,
action="admin_update",
description=f"Admin update: {', '.join(changes)}",
actor=admin_user
)
db.commit()
db.refresh(quote)
return quote
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update quote status: {str(e)}"
)
def get_quote_stats(db: Session) -> QuoteStatsResponse:
"""
Get dashboard statistics for quotes (admin).
Args:
db: Database session
Returns:
QuoteStatsResponse: Statistics about quotes
"""
# Total quotes
total_quotes = db.query(Quote).count()
# Quotes by status
status_counts = (
db.query(Quote.status, func.count(Quote.id))
.group_by(Quote.status)
.all()
)
quotes_by_status = {status: count for status, count in status_counts}
# Total values for submitted quotes
submitted_statuses = [
QuoteStatus.SUBMITTED.value,
QuoteStatus.REVIEWING.value,
QuoteStatus.APPROVED.value
]
value_query = (
db.query(
func.sum(Quote.monthly_total),
func.sum(Quote.setup_total)
)
.filter(Quote.status.in_(submitted_statuses))
.first()
)
total_monthly_value = value_query[0] or Decimal("0.00")
total_setup_value = value_query[1] or Decimal("0.00")
# Quotes this month
month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
quotes_this_month = (
db.query(Quote)
.filter(Quote.created_at >= month_start)
.count()
)
# Quotes submitted this month
quotes_submitted_this_month = (
db.query(Quote)
.filter(
Quote.submitted_at >= month_start,
Quote.submitted_at.isnot(None)
)
.count()
)
# Calculate averages and conversion rate
submitted_count = sum(
quotes_by_status.get(s, 0)
for s in submitted_statuses
)
average_monthly_value = (
total_monthly_value / submitted_count
if submitted_count > 0
else Decimal("0.00")
)
draft_count = quotes_by_status.get(QuoteStatus.DRAFT.value, 0)
total_started = draft_count + submitted_count
conversion_rate = (
(Decimal(submitted_count) / Decimal(total_started) * Decimal("100"))
if total_started > 0
else Decimal("0.00")
)
return QuoteStatsResponse(
total_quotes=total_quotes,
quotes_by_status=quotes_by_status,
total_monthly_value=total_monthly_value,
total_setup_value=total_setup_value,
quotes_this_month=quotes_this_month,
quotes_submitted_this_month=quotes_submitted_this_month,
average_monthly_value=round(average_monthly_value, 2),
conversion_rate=round(conversion_rate, 2)
)
def add_item_to_quote(
db: Session,
access_token: str,
item_data: QuoteItemCreate,
ip_address: Optional[str] = None
) -> Quote:
"""
Add a single item to a quote.
Args:
db: Database session
access_token: The quote's access token
item_data: Item data to add
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_token(db, access_token)
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot add items to a non-draft quote"
)
try:
# Get next sort order
max_order = (
db.query(func.max(QuoteItem.sort_order))
.filter(QuoteItem.quote_id == quote.id)
.scalar()
) or 0
item = QuoteItem(
quote_id=quote.id,
service_name=item_data.service_name,
service_description=item_data.service_description,
category=item_data.category.value,
billing_frequency=item_data.billing_frequency.value,
unit_price=item_data.unit_price,
quantity=item_data.quantity,
setup_fee=item_data.setup_fee,
is_required=item_data.is_required,
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
)
db.add(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="item_added",
description=f"Added item: {item_data.service_name}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add item: {str(e)}"
)
def remove_item_from_quote(
db: Session,
access_token: str,
item_id: UUID,
ip_address: Optional[str] = None
) -> Quote:
"""
Remove an item from a quote.
Args:
db: Database session
access_token: The quote's access token
item_id: UUID of the item to remove
ip_address: IP address of the requester
Returns:
Quote: The updated quote object
"""
quote = get_quote_by_token(db, access_token)
if quote.status != QuoteStatus.DRAFT.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove items from a non-draft quote"
)
# Find the item
item = (
db.query(QuoteItem)
.filter(QuoteItem.id == str(item_id), QuoteItem.quote_id == quote.id)
.first()
)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found in this quote"
)
if item.is_required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove required items from the quote"
)
try:
item_name = item.service_name
db.delete(item)
db.flush()
# Recalculate totals
db.refresh(quote)
monthly, setup, annual = calculate_totals(quote.items)
quote.monthly_total = monthly
quote.setup_total = setup
quote.annual_total = annual
# Log activity
log_activity(
db=db,
quote_id=quote.id,
action="item_removed",
description=f"Removed item: {item_name}",
ip_address=ip_address
)
db.commit()
db.refresh(quote)
return quote
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove item: {str(e)}"
)

View File

@@ -0,0 +1,445 @@
"""
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