# FILE: apps/svc-firm-connectors/main.py # mypy: disable-error-code=union-attr # Firm database integration with practice management systems import asyncio import json import os # Import shared libraries import sys from datetime import datetime from typing import Any import structlog import ulid from fastapi import BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..")) from libs.app_factory import create_app from libs.config import ( BaseAppSettings, create_event_bus, create_neo4j_client, create_vault_client, ) from libs.events import EventBus, EventPayload, EventTopics from libs.neo import Neo4jClient from libs.observability import get_metrics, get_tracer, setup_observability from libs.schemas import ErrorResponse, FirmSyncRequest, FirmSyncResponse from libs.security import VaultTransitHelper, get_current_user, get_tenant_id logger = structlog.get_logger() class FirmConnectorsSettings(BaseAppSettings): """Settings for firm connectors service""" service_name: str = "svc-firm-connectors" # Supported practice management systems supported_systems: list[str] = [ "iris", "sage", "xero", "quickbooks", "freeagent", "kashflow", ] # Sync configuration sync_batch_size: int = 100 max_sync_retries: int = 3 sync_timeout: int = 300 # 5 minutes # Rate limiting api_rate_limit: int = 100 # requests per minute # Data mapping field_mappings_dir: str = "config/firm_mappings" # Create app and settings app, settings = create_app( service_name="svc-firm-connectors", title="Tax Agent Firm Connectors Service", description="Practice management system integration", settings_class=FirmConnectorsSettings, ) # Global clients vault_helper: VaultTransitHelper | None = None neo4j_client: Neo4jClient | None = None event_bus: EventBus | None = None tracer = get_tracer("svc-firm-connectors") metrics = get_metrics() @app.on_event("startup") async def startup_event() -> None: """Initialize service dependencies""" global vault_helper, neo4j_client, event_bus logger.info("Starting firm connectors service") # Setup observability setup_observability(settings) # Initialize Vault helper vault_client = create_vault_client(settings) vault_helper = VaultTransitHelper(vault_client, "tax-agent-transit") # Initialize Neo4j client neo4j_driver = create_neo4j_client(settings) neo4j_client = Neo4jClient(neo4j_driver) # Initialize event bus event_bus = create_event_bus(settings) await event_bus.start() # fmt: skip # pyright: ignore[reportOptionalMemberAccess] logger.info("Firm connectors service started successfully") @app.on_event("shutdown") async def shutdown_event() -> None: """Cleanup service dependencies""" global neo4j_client, event_bus logger.info("Shutting down firm connectors service") if neo4j_client: await neo4j_client.close() if event_bus: await event_bus.stop() logger.info("Firm connectors service shutdown complete") @app.get("/health") async def health_check() -> dict[str, Any]: """Health check endpoint""" return { "status": "healthy", "service": settings.service_name, "version": settings.service_version, "timestamp": datetime.utcnow().isoformat(), "supported_systems": settings.supported_systems, } @app.post("/sync", response_model=FirmSyncResponse) async def sync_firm_data( request_data: FirmSyncRequest, background_tasks: BackgroundTasks, current_user: dict[str, Any] = Depends(get_current_user), tenant_id: str = Depends(get_tenant_id), ) -> FirmSyncResponse: """Sync data from practice management system""" with tracer.start_as_current_span("sync_firm_data") as span: span.set_attribute("system", request_data.system) span.set_attribute("tenant_id", tenant_id) span.set_attribute("sync_type", request_data.sync_type) try: # Validate system if request_data.system not in settings.supported_systems: raise HTTPException( status_code=400, detail=f"Unsupported system: {request_data.system}" ) # Generate sync ID sync_id = str(ulid.new()) span.set_attribute("sync_id", sync_id) # Start background sync background_tasks.add_task( _sync_firm_data_async, request_data.system, request_data.sync_type, request_data.connection_config, tenant_id, sync_id, current_user.get("sub", "system"), ) logger.info( "Firm data sync started", sync_id=sync_id, system=request_data.system, sync_type=request_data.sync_type, ) return FirmSyncResponse( firm_id=request_data.firm_id, status="syncing", message=f"Sync started with ID: {sync_id}", synced_entities=0, errors=[], ) except HTTPException: raise except Exception as e: logger.error("Failed to start firm sync", error=str(e)) raise HTTPException(status_code=500, detail="Failed to start firm sync") @app.get("/sync/{sync_id}") async def get_sync_status( sync_id: str, current_user: dict[str, Any] = Depends(get_current_user), tenant_id: str = Depends(get_tenant_id), ) -> dict[str, Any]: """Get sync status""" with tracer.start_as_current_span("get_sync_status") as span: span.set_attribute("sync_id", sync_id) span.set_attribute("tenant_id", tenant_id) try: # Get sync record from Neo4j query = """ MATCH (s:FirmSync {sync_id: $sync_id, tenant_id: $tenant_id}) WHERE s.retracted_at IS NULL RETURN s """ results = await neo4j_client.run_query( # pyright: ignore[reportOptionalMemberAccess] query, {"sync_id": sync_id, "tenant_id": tenant_id} ) if not results: raise HTTPException(status_code=404, detail="Sync not found") sync_record = results[0]["s"] return { "sync_id": sync_id, "system": sync_record.get("system"), "status": sync_record.get("status"), "records_synced": sync_record.get("records_synced", 0), "total_records": sync_record.get("total_records", 0), "started_at": sync_record.get("started_at"), "completed_at": sync_record.get("completed_at"), "errors": json.loads(sync_record.get("errors", "[]")), } except HTTPException: raise except Exception as e: logger.error("Failed to get sync status", sync_id=sync_id, error=str(e)) raise HTTPException(status_code=500, detail="Failed to get sync status") @app.post("/connections/{system}/test") async def test_connection( system: str, connection_config: dict[str, Any], current_user: dict[str, Any] = Depends(get_current_user), tenant_id: str = Depends(get_tenant_id), ) -> dict[str, Any]: """Test connection to practice management system""" with tracer.start_as_current_span("test_connection") as span: span.set_attribute("system", system) span.set_attribute("tenant_id", tenant_id) try: # Validate system if system not in settings.supported_systems: raise HTTPException( status_code=400, detail=f"Unsupported system: {system}" ) # Test connection based on system if system == "iris": result = await _test_iris_connection(connection_config) elif system == "sage": result = await _test_sage_connection(connection_config) elif system == "xero": result = await _test_xero_connection(connection_config) elif system == "quickbooks": result = await _test_quickbooks_connection(connection_config) elif system == "freeagent": result = await _test_freeagent_connection(connection_config) elif system == "kashflow": result = await _test_kashflow_connection(connection_config) else: raise HTTPException( status_code=400, detail=f"Connection test not implemented for {system}", ) return { "system": system, "connection_status": result["status"], "message": result["message"], "capabilities": result.get("capabilities", []), "test_timestamp": datetime.utcnow().isoformat(), } except HTTPException: raise except Exception as e: logger.error("Connection test failed", system=system, error=str(e)) raise HTTPException( status_code=500, detail=f"Connection test failed: {str(e)}" ) @app.get("/systems") async def list_supported_systems( current_user: dict[str, Any] = Depends(get_current_user), tenant_id: str = Depends(get_tenant_id), ) -> dict[str, Any]: """List supported practice management systems""" try: systems_info: list[Any] = [] for system in settings.supported_systems: system_info = { "system": system, "name": _get_system_name(system), "capabilities": _get_system_capabilities(system), "connection_fields": _get_connection_fields(system), } systems_info.append(system_info) return {"supported_systems": systems_info, "total_systems": len(systems_info)} except Exception as e: logger.error("Failed to list systems", error=str(e)) raise HTTPException(status_code=500, detail="Failed to list systems") async def _sync_firm_data_async( system: str, sync_type: str, connection_config: dict[str, Any], tenant_id: str, sync_id: str, actor: str, ) -> None: """Sync firm data asynchronously""" with tracer.start_as_current_span("sync_firm_data_async") as span: span.set_attribute("sync_id", sync_id) span.set_attribute("system", system) span.set_attribute("sync_type", sync_type) try: # Create sync record await _create_sync_record(sync_id, system, sync_type, tenant_id) # Perform sync based on system if system == "iris": sync_result = await _sync_iris_data( connection_config, sync_type, tenant_id ) elif system == "sage": sync_result = await _sync_sage_data( connection_config, sync_type, tenant_id ) elif system == "xero": sync_result = await _sync_xero_data( connection_config, sync_type, tenant_id ) elif system == "quickbooks": sync_result = await _sync_quickbooks_data( connection_config, sync_type, tenant_id ) elif system == "freeagent": sync_result = await _sync_freeagent_data( connection_config, sync_type, tenant_id ) elif system == "kashflow": sync_result = await _sync_kashflow_data( connection_config, sync_type, tenant_id ) else: raise Exception(f"Sync not implemented for {system}") # Update sync record await _update_sync_record(sync_id, "completed", sync_result) # Update metrics metrics.counter("firm_syncs_completed_total").labels( tenant_id=tenant_id, system=system, sync_type=sync_type ).inc() metrics.histogram("sync_records_count").labels( system=system, sync_type=sync_type ).observe(sync_result["records_synced"]) # Publish completion event event_payload = EventPayload( data={ "sync_id": sync_id, "system": system, "sync_type": sync_type, "tenant_id": tenant_id, "records_synced": sync_result["records_synced"], "entities_created": sync_result.get("entities_created", 0), }, actor=actor, tenant_id=tenant_id, ) await event_bus.publish(EventTopics.FIRM_SYNC_COMPLETED, event_payload) # type: ignore logger.info( "Firm sync completed", sync_id=sync_id, system=system, records=sync_result["records_synced"], ) except Exception as e: logger.error("Firm sync failed", sync_id=sync_id, error=str(e)) # Update sync record with error await _update_sync_record(sync_id, "error", {"error": str(e)}) # Update error metrics metrics.counter("firm_sync_errors_total").labels( tenant_id=tenant_id, system=system, error_type=type(e).__name__ ).inc() async def _test_iris_connection(config: dict[str, Any]) -> dict[str, Any]: """Test IRIS connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["clients", "jobs", "documents"], } async def _test_sage_connection(config: dict[str, Any]) -> dict[str, Any]: """Test Sage connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["customers", "suppliers", "transactions"], } async def _test_xero_connection(config: dict[str, Any]) -> dict[str, Any]: """Test Xero connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["contacts", "invoices", "bank_transactions"], } async def _test_quickbooks_connection(config: dict[str, Any]) -> dict[str, Any]: """Test QuickBooks connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["customers", "vendors", "items", "transactions"], } async def _test_freeagent_connection(config: dict[str, Any]) -> dict[str, Any]: """Test FreeAgent connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["contacts", "projects", "invoices", "expenses"], } async def _test_kashflow_connection(config: dict[str, Any]) -> dict[str, Any]: """Test KashFlow connection""" # Mock implementation await asyncio.sleep(1) return { "status": "success", "message": "Connection successful", "capabilities": ["customers", "suppliers", "invoices", "receipts"], } async def _sync_iris_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from IRIS""" # Mock implementation await asyncio.sleep(2) # Simulate syncing client data mock_clients = [ {"id": "client_1", "name": "John Doe", "utr": "1234567890"}, {"id": "client_2", "name": "Jane Smith", "utr": "0987654321"}, ] entities_created = 0 for client in mock_clients: # Create taxpayer profile in KG taxpayer_properties = { "taxpayer_id": client["id"], "name": client["name"], "utr": client["utr"], "tenant_id": tenant_id, "source": "iris_sync", "extractor_version": "1.0.0", "valid_from": datetime.utcnow(), "asserted_at": datetime.utcnow(), } await neo4j_client.create_node("TaxpayerProfile", taxpayer_properties) # fmt: skip # pyright: ignore[reportOptionalMemberAccess] entities_created += 1 return { "records_synced": len(mock_clients), "entities_created": entities_created, "sync_type": sync_type, } async def _sync_sage_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from Sage""" # Mock implementation await asyncio.sleep(2) return {"records_synced": 5, "entities_created": 5, "sync_type": sync_type} async def _sync_xero_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from Xero""" # Mock implementation await asyncio.sleep(2) return {"records_synced": 8, "entities_created": 8, "sync_type": sync_type} async def _sync_quickbooks_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from QuickBooks""" # Mock implementation await asyncio.sleep(2) return {"records_synced": 12, "entities_created": 12, "sync_type": sync_type} async def _sync_freeagent_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from FreeAgent""" # Mock implementation await asyncio.sleep(2) return {"records_synced": 6, "entities_created": 6, "sync_type": sync_type} async def _sync_kashflow_data( config: dict[str, Any], sync_type: str, tenant_id: str ) -> dict[str, Any]: """Sync data from KashFlow""" # Mock implementation await asyncio.sleep(2) return {"records_synced": 4, "entities_created": 4, "sync_type": sync_type} def _get_system_name(system: str) -> str: """Get human-readable system name""" names = { "iris": "IRIS Practice Management", "sage": "Sage Practice Management", "xero": "Xero", "quickbooks": "QuickBooks", "freeagent": "FreeAgent", "kashflow": "KashFlow", } return names.get(system, system.title()) def _get_system_capabilities(system: str) -> list[str]: """Get system capabilities""" capabilities = { "iris": ["clients", "jobs", "documents", "time_tracking"], "sage": ["customers", "suppliers", "transactions", "reports"], "xero": ["contacts", "invoices", "bank_transactions", "reports"], "quickbooks": ["customers", "vendors", "items", "transactions", "reports"], "freeagent": ["contacts", "projects", "invoices", "expenses", "time_tracking"], "kashflow": ["customers", "suppliers", "invoices", "receipts", "reports"], } return capabilities.get(system, []) def _get_connection_fields(system: str) -> list[dict[str, Any]]: """Get required connection fields for system""" fields = { "iris": [ { "name": "api_key", "type": "string", "required": True, "description": "IRIS API Key", }, { "name": "base_url", "type": "string", "required": True, "description": "IRIS Base URL", }, ], "sage": [ { "name": "username", "type": "string", "required": True, "description": "Sage Username", }, { "name": "password", "type": "password", "required": True, "description": "Sage Password", }, { "name": "database", "type": "string", "required": True, "description": "Database Name", }, ], "xero": [ { "name": "client_id", "type": "string", "required": True, "description": "Xero Client ID", }, { "name": "client_secret", "type": "password", "required": True, "description": "Xero Client Secret", }, { "name": "tenant_id", "type": "string", "required": True, "description": "Xero Tenant ID", }, ], "quickbooks": [ { "name": "client_id", "type": "string", "required": True, "description": "QuickBooks Client ID", }, { "name": "client_secret", "type": "password", "required": True, "description": "QuickBooks Client Secret", }, { "name": "company_id", "type": "string", "required": True, "description": "Company ID", }, ], "freeagent": [ { "name": "client_id", "type": "string", "required": True, "description": "FreeAgent Client ID", }, { "name": "client_secret", "type": "password", "required": True, "description": "FreeAgent Client Secret", }, ], "kashflow": [ { "name": "username", "type": "string", "required": True, "description": "KashFlow Username", }, { "name": "password", "type": "password", "required": True, "description": "KashFlow Password", }, ], } return fields.get(system, []) async def _create_sync_record( sync_id: str, system: str, sync_type: str, tenant_id: str ) -> None: """Create sync record in knowledge graph""" sync_properties = { "sync_id": sync_id, "system": system, "sync_type": sync_type, "tenant_id": tenant_id, "status": "running", "started_at": datetime.utcnow().isoformat(), "records_synced": 0, "errors": "[]", "source": "firm_connectors", "extractor_version": "1.0.0", "valid_from": datetime.utcnow(), "asserted_at": datetime.utcnow(), } await neo4j_client.create_node("FirmSync", sync_properties) # fmt: skip # pyright: ignore[reportOptionalMemberAccess] async def _update_sync_record( sync_id: str, status: str, result: dict[str, Any] ) -> None: """Update sync record with results""" update_properties = { "status": status, "completed_at": datetime.utcnow().isoformat(), "records_synced": result.get("records_synced", 0), "total_records": result.get("total_records", 0), "errors": json.dumps(result.get("errors", [])), } # This would update the existing node # For now, just log logger.debug( "Sync record updated", sync_id=sync_id, status=status, properties=update_properties, ) @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: """Handle HTTP exceptions with RFC7807 format""" return JSONResponse( status_code=exc.status_code, content=ErrorResponse( type=f"https://httpstatuses.com/{exc.status_code}", title=exc.detail, status=exc.status_code, detail=exc.detail, instance=str(request.url), trace_id="", ).model_dump(), ) if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8011, reload=True, log_config=None)