""" vTiger Cloud CRM Integration Service Handles subscription and sales order data retrieval """ import logging import json import aiohttp from typing import List, Dict, Optional from app.core.config import settings logger = logging.getLogger(__name__) class VTigerService: """Service for integrating with vTiger Cloud CRM via REST API""" def __init__(self): self.base_url = getattr(settings, 'VTIGER_URL', None) self.username = getattr(settings, 'VTIGER_USERNAME', None) self.api_key = getattr(settings, 'VTIGER_API_KEY', None) # REST API endpoint if self.base_url: self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default" else: self.rest_endpoint = None if not all([self.base_url, self.username, self.api_key]): logger.warning("⚠️ vTiger credentials not fully configured") def _get_auth(self): """Get HTTP Basic Auth credentials""" if not self.api_key: raise ValueError("VTIGER_API_KEY not configured") return aiohttp.BasicAuth(self.username, self.api_key) async def query(self, query_string: str) -> List[Dict]: """ Execute a query on vTiger REST API Args: query_string: SQL-like query (e.g., "SELECT * FROM Accounts;") Returns: List of records """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured") try: auth = self._get_auth() async with aiohttp.ClientSession() as session: async with session.get( f"{self.rest_endpoint}/query", params={"query": query_string}, auth=auth ) as response: text = await response.text() if response.status == 200: # vTiger returns text/json instead of application/json import json try: data = json.loads(text) except json.JSONDecodeError as e: logger.error(f"❌ Invalid JSON in query response: {text[:200]}") return [] if data.get('success'): result = data.get('result', []) logger.info(f"✅ Query returned {len(result)} records") return result else: logger.error(f"❌ vTiger query failed: {data.get('error')}") return [] else: logger.error(f"❌ vTiger query HTTP error {response.status}") logger.error(f"Query: {query_string}") logger.error(f"Response: {text[:500]}") return [] except Exception as e: logger.error(f"❌ vTiger query error: {e}") return [] async def get_account_by_id(self, account_id: str) -> Optional[Dict]: """ Fetch a single account by ID from vTiger Args: account_id: vTiger account ID (e.g., "3x760") Returns: Account record or None """ if not account_id: logger.warning("⚠️ No account ID provided") return None try: query = f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;" results = await self.query(query) if results and len(results) > 0: logger.info(f"✅ Found account {account_id}") return results[0] else: logger.warning(f"⚠️ No account found with ID {account_id}") return None except Exception as e: logger.error(f"❌ Error fetching account {account_id}: {e}") return None async def update_account(self, account_id: str, update_data: Dict) -> bool: """ Update an account in vTiger Args: account_id: vTiger account ID (e.g., "3x760") update_data: Dictionary of fields to update Returns: True if successful, False otherwise """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured") try: # Fetch current account first - vTiger requires modifiedtime for updates current_account = await self.get_account_by_id(account_id) if not current_account: logger.error(f"❌ Account {account_id} not found for update") return False auth = self._get_auth() # Build payload with current data + updates # Include essential fields for vTiger update validation payload = { 'id': account_id, 'accountname': current_account.get('accountname'), 'assigned_user_id': current_account.get('assigned_user_id'), 'modifiedtime': current_account.get('modifiedtime'), **update_data } logger.info(f"🔄 vTiger update payload: {payload}") async with aiohttp.ClientSession() as session: async with session.post( f"{self.rest_endpoint}/update", json=payload, auth=auth ) as response: text = await response.text() if response.status == 200: import json try: data = json.loads(text) if data.get('success'): logger.info(f"✅ Updated vTiger account {account_id}") return True else: logger.error(f"❌ vTiger update failed: {data.get('error')}") logger.error(f"Full response: {text}") return False except json.JSONDecodeError: logger.error(f"❌ Invalid JSON in update response: {text[:200]}") return False else: logger.error(f"❌ vTiger update HTTP error {response.status}") logger.error(f"Full response: {text[:2000]}") return False except Exception as e: logger.error(f"❌ Error updating vTiger account {account_id}: {e}") return False async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]: """ Fetch sales orders for a customer from vTiger Args: vtiger_account_id: vTiger account ID (e.g., "3x760") Returns: List of sales order records """ if not vtiger_account_id: logger.warning("⚠️ No vTiger account ID provided") return [] try: # Query for sales orders linked to this account query = f"SELECT * FROM SalesOrder WHERE account_id='{vtiger_account_id}';" logger.info(f"🔍 Fetching sales orders for vTiger account {vtiger_account_id}") orders = await self.query(query) logger.info(f"✅ Found {len(orders)} sales orders") return orders except Exception as e: logger.error(f"❌ Error fetching sales orders: {e}") return [] async def get_customer_subscriptions(self, vtiger_account_id: str) -> List[Dict]: """ Fetch subscriptions for a customer from vTiger Args: vtiger_account_id: vTiger account ID (e.g., "3x760") Returns: List of subscription records """ if not vtiger_account_id: logger.warning("⚠️ No vTiger account ID provided") return [] try: # Query for subscriptions linked to this account (note: module name is singular "Subscription") query = f"SELECT * FROM Subscription WHERE account_id='{vtiger_account_id}';" logger.info(f"🔍 Fetching subscriptions for vTiger account {vtiger_account_id}") subscriptions = await self.query(query) logger.info(f"✅ Found {len(subscriptions)} subscriptions") return subscriptions except Exception as e: logger.error(f"❌ Error fetching subscriptions: {e}") return [] async def get_subscription(self, subscription_id: str) -> Optional[Dict]: """ Fetch full subscription details with LineItems from vTiger Args: subscription_id: vTiger subscription ID (e.g., "72x123") Returns: Subscription record with LineItems array or None """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured") try: auth = self._get_auth() async with aiohttp.ClientSession() as session: url = f"{self.rest_endpoint}/retrieve?id={subscription_id}" async with session.get(url, auth=auth) as response: if response.status == 200: text = await response.text() data = json.loads(text) if data.get('success'): logger.info(f"✅ Retrieved subscription {subscription_id}") return data.get('result') else: logger.error(f"❌ vTiger API error: {data.get('error')}") return None else: logger.error(f"❌ HTTP {response.status} retrieving subscription") return None except Exception as e: logger.error(f"❌ Error retrieving subscription: {e}") return None async def get_account(self, vtiger_account_id: str) -> Optional[Dict]: """ Fetch account details from vTiger Args: vtiger_account_id: vTiger account ID (e.g., "3x760") Returns: Account record with BMC Låst field (cf_accounts_bmclst) or None """ if not vtiger_account_id: logger.warning("⚠️ No vTiger account ID provided") return None try: # Query for account by ID query = f"SELECT * FROM Accounts WHERE id='{vtiger_account_id}';" logger.info(f"🔍 Fetching account details for {vtiger_account_id}") accounts = await self.query(query) if accounts: logger.info(f"✅ Found account: {accounts[0].get('accountname', 'Unknown')}") return accounts[0] else: logger.warning(f"⚠️ No account found with ID {vtiger_account_id}") return None except Exception as e: logger.error(f"❌ Error fetching account: {e}") return None async def test_connection(self) -> bool: """ Test vTiger connection using /me endpoint Returns: True if connection successful """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured in .env") try: auth = self._get_auth() logger.info(f"🔑 Testing vTiger connection...") async with aiohttp.ClientSession() as session: async with session.get( f"{self.rest_endpoint}/me", auth=auth ) as response: if response.status == 200: # vTiger returns text/json instead of application/json text = await response.text() import json data = json.loads(text) if data.get('success'): user_name = data['result'].get('user_name') logger.info(f"✅ vTiger connection successful (user: {user_name})") return True else: logger.error(f"❌ vTiger API returned success=false: {data}") return False else: error_text = await response.text() logger.error(f"❌ vTiger connection failed: HTTP {response.status}: {error_text}") return False except Exception as e: logger.error(f"❌ vTiger connection error: {e}") return False async def create_subscription( self, account_id: str, subject: str, startdate: str, generateinvoiceevery: str, subscriptionstatus: str = "Active", enddate: Optional[str] = None, products: Optional[List[Dict]] = None ) -> Dict: """ Create a new subscription in vTiger Args: account_id: vTiger account ID (e.g., "3x123") subject: Subscription subject/name startdate: Start date (YYYY-MM-DD) generateinvoiceevery: Frequency ("Monthly", "Quarterly", "Yearly") subscriptionstatus: Status ("Active", "Cancelled", "Stopped") enddate: End date (YYYY-MM-DD), optional products: List of products [{"productid": "id", "quantity": 1, "listprice": 100}] """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured") try: auth = self._get_auth() # Build subscription data subscription_data = { "account_id": account_id, "subject": subject, "startdate": startdate, "generateinvoiceevery": generateinvoiceevery, "subscriptionstatus": subscriptionstatus, } if enddate: subscription_data["enddate"] = enddate # Add products if provided if products: subscription_data["products"] = products async with aiohttp.ClientSession() as session: create_url = f"{self.rest_endpoint}/create" payload = { "elementType": "Subscription", "element": subscription_data } logger.info(f"📤 Creating subscription: {subject}") async with session.post(create_url, json=payload, auth=auth) as response: if response.status == 200: data = await response.json() if data.get("success"): result = data.get("result", {}) logger.info(f"✅ Created subscription: {result.get('id')}") return result else: error_msg = data.get("error", {}).get("message", "Unknown error") raise Exception(f"vTiger API error: {error_msg}") else: error_text = await response.text() raise Exception(f"HTTP {response.status}: {error_text}") except Exception as e: logger.error(f"❌ Error creating subscription: {e}") raise async def update_subscription(self, subscription_id: str, updates: Dict, line_items: List[Dict] = None) -> Dict: """ Update a subscription in vTiger with optional line item price updates Args: subscription_id: vTiger subscription ID (e.g., "72x123") updates: Dictionary of fields to update (subject, startdate, etc.) line_items: Optional list of line items with updated prices Each item: {"productid": "6x123", "quantity": "3", "listprice": "299.00"} """ if not self.rest_endpoint: raise ValueError("VTIGER_URL not configured") try: auth = self._get_auth() async with aiohttp.ClientSession() as session: # For custom fields (cf_*), we need to retrieve first for context if any(k.startswith('cf_') for k in updates.keys()): logger.info(f"📥 Retrieving subscription {subscription_id} for custom field update") current_sub = await self.get_subscription(subscription_id) if current_sub: # Only include essential fields + custom field update essential_fields = ['id', 'account_id', 'subject', 'startdate', 'generateinvoiceevery', 'subscriptionstatus'] element_data = { k: current_sub[k] for k in essential_fields if k in current_sub } element_data.update(updates) element_data['id'] = subscription_id else: element_data = {"id": subscription_id, **updates} else: element_data = {"id": subscription_id, **updates} update_url = f"{self.rest_endpoint}/update" # Add LineItems if provided if line_items: element_data["LineItems"] = line_items logger.info(f"📝 Updating {len(line_items)} line items") payload = { "elementType": "Subscription", "element": element_data } logger.info(f"📤 Updating subscription {subscription_id}: {list(updates.keys())}") logger.debug(f"Payload: {json.dumps(payload, indent=2)}") async with session.post(update_url, json=payload, auth=auth) as response: if response.status == 200: text = await response.text() data = json.loads(text) if data.get("success"): result = data.get("result", {}) logger.info(f"✅ Updated subscription: {subscription_id}") return result else: error_msg = data.get("error", {}).get("message", "Unknown error") logger.error(f"❌ vTiger error response: {data.get('error')}") raise Exception(f"vTiger API error: {error_msg}") else: error_text = await response.text() logger.error(f"❌ vTiger HTTP error: {error_text[:500]}") raise Exception(f"HTTP {response.status}: {error_text}") except Exception as e: logger.error(f"❌ Error updating subscription: {e}") raise # Singleton instance _vtiger_service = None def get_vtiger_service() -> VTigerService: """Get or create vTiger service singleton""" global _vtiger_service if _vtiger_service is None: _vtiger_service = VTigerService() return _vtiger_service