""" Simply-CRM Integration Service Sync abonnementer, fakturaer og kunder fra Simply-CRM (VTiger fork) Simply-CRM bruger webservice.php endpoint med challenge-token authentication: - Endpoint: /webservice.php - Auth: getchallenge + login med MD5 hash - Moduler: Accounts, Invoice, Products, SalesOrder """ import logging import json import hashlib import aiohttp from typing import List, Dict, Optional, Any from datetime import datetime, date from app.core.config import settings logger = logging.getLogger(__name__) class SimplyCRMService: """Service for integrating with Simply-CRM via webservice.php (VTiger fork)""" def __init__(self): # Try SIMPLYCRM_* first, fallback to OLD_VTIGER_* for backward compatibility self.base_url = getattr(settings, 'SIMPLYCRM_URL', None) or getattr(settings, 'OLD_VTIGER_URL', None) self.username = getattr(settings, 'SIMPLYCRM_USERNAME', None) or getattr(settings, 'OLD_VTIGER_USERNAME', None) self.access_key = getattr(settings, 'SIMPLYCRM_API_KEY', None) or getattr(settings, 'OLD_VTIGER_API_KEY', None) self.session_name: Optional[str] = None self.session: Optional[aiohttp.ClientSession] = None if not all([self.base_url, self.username, self.access_key]): logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)") async def __aenter__(self): """Context manager entry - create session and login""" self.session = aiohttp.ClientSession() await self.login() return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Context manager exit - close session""" if self.session: await self.session.close() self.session = None self.session_name = None async def login(self) -> bool: """ Login to Simply-CRM using challenge-token authentication Returns: True if login successful """ if not self.base_url or not self.username or not self.access_key: logger.error("❌ Simply-CRM credentials not configured") return False try: if not self.session: self.session = aiohttp.ClientSession() # Step 1: Get challenge token async with self.session.get( f"{self.base_url}/webservice.php", params={"operation": "getchallenge", "username": self.username}, timeout=aiohttp.ClientTimeout(total=30) ) as response: if not response.ok: logger.error(f"❌ Simply-CRM challenge request failed: {response.status}") return False data = await response.json() if not data.get("success"): logger.error(f"❌ Simply-CRM challenge failed: {data}") return False token = data["result"]["token"] # Step 2: Generate access key hash access_key_hash = hashlib.md5(f"{token}{self.access_key}".encode()).hexdigest() # Step 3: Login async with self.session.post( f"{self.base_url}/webservice.php", data={ "operation": "login", "username": self.username, "accessKey": access_key_hash }, timeout=aiohttp.ClientTimeout(total=30) ) as response: if not response.ok: logger.error(f"❌ Simply-CRM login request failed: {response.status}") return False data = await response.json() if not data.get("success"): logger.error(f"❌ Simply-CRM login failed: {data}") return False self.session_name = data["result"]["sessionName"] session_preview = self.session_name[:20] if self.session_name else "unknown" logger.info(f"✅ Simply-CRM login successful (session: {session_preview}...)") return True except aiohttp.ClientError as e: logger.error(f"❌ Simply-CRM connection error: {e}") return False except Exception as e: logger.error(f"❌ Simply-CRM login error: {e}") return False async def test_connection(self) -> bool: """ Test Simply-CRM connection by logging in Returns: True if connection successful """ try: async with aiohttp.ClientSession() as session: self.session = session result = await self.login() self.session = None return result except Exception as e: logger.error(f"❌ Simply-CRM connection test failed: {e}") return False async def _ensure_session(self): """Ensure we have an active session""" if not self.session: self.session = aiohttp.ClientSession() if not self.session_name: await self.login() async def query(self, query_string: str) -> List[Dict]: """ Execute a query on Simply-CRM Args: query_string: SQL-like query (e.g., "SELECT * FROM Accounts LIMIT 100;") Returns: List of records """ await self._ensure_session() if not self.session_name or not self.session: logger.error("❌ Not logged in to Simply-CRM") return [] try: async with self.session.get( f"{self.base_url}/webservice.php", params={ "operation": "query", "sessionName": self.session_name, "query": query_string }, timeout=aiohttp.ClientTimeout(total=60) ) as response: if not response.ok: logger.error(f"❌ Simply-CRM query failed: {response.status}") return [] data = await response.json() if not data.get("success"): error = data.get("error", {}) logger.error(f"❌ Simply-CRM query error: {error}") return [] result = data.get("result", []) logger.debug(f"✅ Simply-CRM query returned {len(result)} records") return result except Exception as e: logger.error(f"❌ Simply-CRM query error: {e}") return [] async def retrieve(self, record_id: str) -> Optional[Dict]: """ Retrieve a specific record by ID Args: record_id: VTiger-style ID (e.g., "6x12345") Returns: Record dict or None """ await self._ensure_session() if not self.session_name or not self.session: return None try: async with self.session.get( f"{self.base_url}/webservice.php", params={ "operation": "retrieve", "sessionName": self.session_name, "id": record_id }, timeout=aiohttp.ClientTimeout(total=30) ) as response: if not response.ok: return None data = await response.json() if data.get("success"): return data.get("result") return None except Exception as e: logger.error(f"❌ Simply-CRM retrieve error: {e}") return None # ========================================================================= # SUBSCRIPTIONS # ========================================================================= async def fetch_subscriptions(self, limit: int = 100, offset: int = 0) -> List[Dict]: """ Fetch subscriptions from Simply-CRM via recurring SalesOrders SalesOrders with enable_recurring='1' are the subscription source in Simply-CRM. """ query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};" return await self.query(query) async def fetch_active_subscriptions(self) -> List[Dict]: """ Fetch all active recurring SalesOrders (subscriptions) Returns deduplicated list of unique SalesOrders. """ all_records = [] offset = 0 limit = 100 seen_ids = set() while True: query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};" batch = await self.query(query) if not batch: break # Deduplicate by id (VTiger returns one row per line item) for record in batch: record_id = record.get('id') if record_id and record_id not in seen_ids: seen_ids.add(record_id) all_records.append(record) if len(batch) < limit: break offset += limit logger.info(f"📊 Fetched {len(all_records)} unique recurring SalesOrders from Simply-CRM") return all_records # ========================================================================= # INVOICES # ========================================================================= async def fetch_invoices(self, limit: int = 100, offset: int = 0) -> List[Dict]: """Fetch invoices from Simply-CRM""" query = f"SELECT * FROM Invoice LIMIT {offset}, {limit};" return await self.query(query) async def fetch_invoices_by_account(self, account_id: str) -> List[Dict]: """Fetch all invoices for a specific account""" query = f"SELECT * FROM Invoice WHERE account_id = '{account_id}';" return await self.query(query) async def fetch_invoice_with_lines(self, invoice_id: str) -> Optional[Dict]: """Fetch invoice with full line item details""" return await self.retrieve(invoice_id) async def fetch_recent_invoices(self, days: int = 30) -> List[Dict]: """Fetch invoices from the last N days""" from datetime import datetime, timedelta since_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') all_invoices = [] offset = 0 limit = 100 while True: query = f"SELECT * FROM Invoice WHERE invoicedate >= '{since_date}' LIMIT {offset}, {limit};" batch = await self.query(query) if not batch: break all_invoices.extend(batch) if len(batch) < limit: break offset += limit logger.info(f"📊 Fetched {len(all_invoices)} invoices from last {days} days") return all_invoices # ========================================================================= # ACCOUNTS (CUSTOMERS) # ========================================================================= async def fetch_accounts(self, limit: int = 100, offset: int = 0) -> List[Dict]: """Fetch accounts (customers) from Simply-CRM""" query = f"SELECT * FROM Accounts LIMIT {offset}, {limit};" return await self.query(query) async def fetch_account_by_id(self, account_id: str) -> Optional[Dict]: """Fetch account by VTiger ID (e.g., '11x12345')""" return await self.retrieve(account_id) async def fetch_account_by_name(self, name: str) -> Optional[Dict]: """Find account by name""" query = f"SELECT * FROM Accounts WHERE accountname = '{name}';" results = await self.query(query) return results[0] if results else None async def fetch_account_by_cvr(self, cvr: str) -> Optional[Dict]: """Find account by CVR number""" query = f"SELECT * FROM Accounts WHERE siccode = '{cvr}';" results = await self.query(query) if not results: # Try vat_number field query = f"SELECT * FROM Accounts WHERE vat_number = '{cvr}';" results = await self.query(query) return results[0] if results else None async def fetch_all_accounts(self) -> List[Dict]: """Fetch all accounts (with pagination)""" all_accounts = [] offset = 0 limit = 100 while True: batch = await self.fetch_accounts(limit, offset) if not batch: break all_accounts.extend(batch) if len(batch) < limit: break offset += limit logger.info(f"📊 Fetched {len(all_accounts)} accounts from Simply-CRM") return all_accounts # ========================================================================= # PRODUCTS # ========================================================================= async def fetch_products(self, limit: int = 100, offset: int = 0) -> List[Dict]: """Fetch products from Simply-CRM""" query = f"SELECT * FROM Products LIMIT {offset}, {limit};" return await self.query(query) async def fetch_product_by_number(self, product_number: str) -> Optional[Dict]: """Find product by product number""" query = f"SELECT * FROM Products WHERE product_no = '{product_number}';" results = await self.query(query) return results[0] if results else None # ========================================================================= # SYNC HELPERS # ========================================================================= async def get_modified_since(self, module: str, since_date: str) -> List[Dict]: """Get records modified since a specific date""" all_records = [] offset = 0 limit = 100 while True: query = f"SELECT * FROM {module} WHERE modifiedtime >= '{since_date}' LIMIT {offset}, {limit};" batch = await self.query(query) if not batch: break all_records.extend(batch) if len(batch) < limit: break offset += limit return all_records def extract_subscription_data(self, raw_salesorder: Dict) -> Dict: """ Extract and normalize subscription data from Simply-CRM SalesOrder format SalesOrders with enable_recurring='1' are the subscription source. Key fields: - recurring_frequency: Monthly, Quarterly, Yearly - start_period / end_period: Subscription period - cf_abonnementsperiode_dato: Binding end date - arr: Annual Recurring Revenue - sostatus: Created, Approved, Lukket """ return { 'simplycrm_id': raw_salesorder.get('id'), 'salesorder_no': raw_salesorder.get('salesorder_no'), 'name': raw_salesorder.get('subject', ''), 'account_id': raw_salesorder.get('account_id'), 'status': self._map_salesorder_status(raw_salesorder.get('sostatus')), 'start_date': raw_salesorder.get('start_period'), 'end_date': raw_salesorder.get('end_period'), 'binding_end_date': raw_salesorder.get('cf_abonnementsperiode_dato'), 'billing_frequency': self._map_billing_frequency(raw_salesorder.get('recurring_frequency')), 'auto_invoicing': raw_salesorder.get('auto_invoicing'), 'last_recurring_date': raw_salesorder.get('last_recurring_date'), 'total_amount': self._parse_amount(raw_salesorder.get('hdnGrandTotal')), 'subtotal': self._parse_amount(raw_salesorder.get('hdnSubTotal')), 'arr': self._parse_amount(raw_salesorder.get('arr')), 'currency': 'DKK', 'modified_time': raw_salesorder.get('modifiedtime'), 'created_time': raw_salesorder.get('createdtime'), 'eco_order_number': raw_salesorder.get('eco_order_number'), } def _map_salesorder_status(self, status: Optional[str]) -> str: """Map Simply-CRM SalesOrder status to OmniSync status""" if not status: return 'active' status_map = { 'Created': 'active', 'Approved': 'active', 'Delivered': 'active', 'Lukket': 'cancelled', 'Cancelled': 'cancelled', } return status_map.get(status, 'active') def extract_invoice_data(self, raw_invoice: Dict) -> Dict: """Extract and normalize invoice data from Simply-CRM format""" return { 'simplycrm_id': raw_invoice.get('id'), 'invoice_number': raw_invoice.get('invoice_no'), 'invoice_date': raw_invoice.get('invoicedate'), 'account_id': raw_invoice.get('account_id'), 'status': self._map_invoice_status(raw_invoice.get('invoicestatus')), 'subtotal': self._parse_amount(raw_invoice.get('hdnSubTotal')), 'tax_amount': self._parse_amount(raw_invoice.get('hdnTax')), 'total_amount': self._parse_amount(raw_invoice.get('hdnGrandTotal')), 'currency': raw_invoice.get('currency_id', 'DKK'), 'line_items': raw_invoice.get('LineItems', []), 'subscription_period': raw_invoice.get('cf_subscription_period'), 'is_subscription': raw_invoice.get('invoicestatus') == 'Auto Created', 'modified_time': raw_invoice.get('modifiedtime'), } def _map_subscription_status(self, status: Optional[str]) -> str: """Map Simply-CRM subscription status to OmniSync status""" if not status: return 'active' status_map = { 'Active': 'active', 'Cancelled': 'cancelled', 'Expired': 'expired', 'Suspended': 'suspended', 'Pending': 'draft', } return status_map.get(status, 'active') def _map_invoice_status(self, status: Optional[str]) -> str: """Map Simply-CRM invoice status""" if not status: return 'active' status_map = { 'Created': 'active', 'Approved': 'active', 'Sent': 'active', 'Paid': 'paid', 'Cancelled': 'cancelled', 'Credit Invoice': 'credited', 'Auto Created': 'active', } return status_map.get(status, 'active') def _map_billing_frequency(self, frequency: Optional[str]) -> str: """Map billing frequency to standard format""" if not frequency: return 'monthly' freq_map = { 'Monthly': 'monthly', 'Quarterly': 'quarterly', 'Semi-annually': 'semi_annual', 'Annually': 'yearly', 'Yearly': 'yearly', } return freq_map.get(frequency, 'monthly') def _parse_amount(self, value: Any) -> float: """Parse amount from string or number""" if value is None: return 0.0 if isinstance(value, (int, float)): return float(value) try: cleaned = str(value).replace(' ', '').replace(',', '.').replace('DKK', '').replace('kr', '') return float(cleaned) except (ValueError, TypeError): return 0.0 # Singleton instance simplycrm_service = SimplyCRMService()