Update with QB Desktop connection

This commit is contained in:
2026-02-16 12:15:16 -05:00
parent 2f4326ec15
commit dedb2db026
5 changed files with 2256 additions and 315 deletions
+569 -30
View File
@@ -19,6 +19,7 @@ from werkzeug.utils import secure_filename
from src.config.settings import Settings, QBOCredentials, MappingTemplate, FieldMapping from src.config.settings import Settings, QBOCredentials, MappingTemplate, FieldMapping
from src.api.qbo_client import QBOClient from src.api.qbo_client import QBOClient
from src.api.qbd_client import QBDesktopClient, QBDCredentials, QBDesktopError, check_qbd_availability
from src.core.excel_parser import ExcelParser, ParseResult from src.core.excel_parser import ExcelParser, ParseResult
from src.core.import_processor import ImportProcessor, ImportResult, ImportStatus from src.core.import_processor import ImportProcessor, ImportResult, ImportStatus
from dataclasses import asdict from dataclasses import asdict
@@ -60,8 +61,17 @@ def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_connection_type():
"""Get the current connection type (qbo or qbd)."""
return session.get('connection_type', 'qbo')
def get_qbo_client(): def get_qbo_client():
"""Get or create QBO client from session or saved credentials.""" """Get or create QBO client from session or saved credentials."""
# Check if using QBD instead
if get_connection_type() == 'qbd':
return None
# First try session # First try session
creds_data = session.get('qbo_credentials') creds_data = session.get('qbo_credentials')
if creds_data: if creds_data:
@@ -88,12 +98,55 @@ def get_qbo_client():
return None return None
def get_qbd_client():
"""Get QBD client from session."""
if get_connection_type() != 'qbd':
return None
qbd_data = session.get('qbd_connection')
if qbd_data and qbd_data.get('connected'):
# Create new client with saved credentials
creds = QBDCredentials(
application_name=qbd_data.get('application_name', 'QBO Excel Sync'),
company_file=qbd_data.get('company_file', '')
)
client = QBDesktopClient(creds)
# We need to reconnect each request since COM objects don't persist
try:
client.connect()
return client
except Exception as e:
logger.error(f"Failed to reconnect to QBD: {e}")
return None
return None
def get_active_client():
"""Get the active QB client (either QBO or QBD)."""
conn_type = get_connection_type()
if conn_type == 'qbd':
return get_qbd_client()
else:
return get_qbo_client()
@app.context_processor @app.context_processor
def inject_common_variables(): def inject_common_variables():
"""Inject common variables into all templates.""" """Inject common variables into all templates."""
# Get connection type directly from session
connection_type = session.get('connection_type', 'qbo')
is_connected = False
company_name = ""
if connection_type == 'qbd':
# Get QBD connection data directly
qbd_data = session.get('qbd_connection')
if qbd_data:
is_connected = qbd_data.get('connected', False)
company_name = qbd_data.get('company_name', '')
else:
client = get_qbo_client() client = get_qbo_client()
is_connected = client.is_authenticated if client else False is_connected = client.is_authenticated if client else False
company_name = ""
if is_connected: if is_connected:
try: try:
@@ -102,10 +155,32 @@ def inject_common_variables():
except Exception: except Exception:
pass pass
# For QBD availability - just check if we're on Windows and have pywin32
qbd_available = False
qbd_running = False
import sys
if sys.platform == 'win32':
try:
import pythoncom
from win32com.client import Dispatch
qbd_available = True
# If we're connected, QB is obviously running
if connection_type == 'qbd' and is_connected:
qbd_running = True
else:
# Assume running if available - actual check happens via API
qbd_running = True
except ImportError:
pass
return { return {
'is_connected': is_connected, 'is_connected': is_connected,
'company_name': company_name, 'company_name': company_name,
'environment': settings.qbo_environment 'environment': settings.qbo_environment,
'connection_type': connection_type,
'qbd_available': qbd_available,
'qbd_running': qbd_running
} }
@@ -137,20 +212,11 @@ def log_action(action: str, details: str = "", level: str = "info"):
def index(): def index():
"""Home page / Dashboard.""" """Home page / Dashboard."""
log_action("PAGE_VIEW", "Dashboard") log_action("PAGE_VIEW", "Dashboard")
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
company_name = ""
if is_connected: # Don't pass is_connected and company_name - let the context processor handle it
try: # This allows both QBO and QBD connection status to be shown correctly
company_info = client.get_company_info()
company_name = company_info.get('CompanyName', '')
except Exception as e:
logger.warning(f"Failed to get company info: {e}")
return render_template('index.html', return render_template('index.html',
is_connected=is_connected,
company_name=company_name,
environment=settings.qbo_environment) environment=settings.qbo_environment)
@@ -159,21 +225,12 @@ def connection():
"""Connection management page.""" """Connection management page."""
log_action("PAGE_VIEW", "Connection") log_action("PAGE_VIEW", "Connection")
creds = settings.get_credentials() creds = settings.get_credentials()
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
company_name = "" # Don't pass is_connected and company_name - let the context processor handle it
if is_connected: # This allows QBD connection status to be shown correctly
try:
company_info = client.get_company_info()
company_name = company_info.get('CompanyName', '')
except Exception:
pass
return render_template('connection.html', return render_template('connection.html',
credentials=creds, credentials=creds,
is_connected=is_connected,
company_name=company_name,
environment=settings.qbo_environment) environment=settings.qbo_environment)
@@ -181,14 +238,12 @@ def connection():
def import_page(): def import_page():
"""Import page.""" """Import page."""
log_action("PAGE_VIEW", "Import") log_action("PAGE_VIEW", "Import")
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
# Get available templates # Get available templates
templates = settings.get_templates() templates = settings.get_templates()
# Don't pass is_connected - let the context processor handle it
return render_template('import.html', return render_template('import.html',
is_connected=is_connected,
templates=templates, templates=templates,
environment=settings.qbo_environment) environment=settings.qbo_environment)
@@ -391,10 +446,491 @@ def disconnect():
return jsonify({'success': False, 'error': str(e)}), 400 return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/session/debug', methods=['GET'])
def session_debug():
"""Debug endpoint to check session contents."""
# Get session ID if using filesystem sessions
session_id = None
try:
from flask import request
session_id = request.cookies.get('session')
except:
pass
return jsonify({
'session_id': session_id,
'connection_type': session.get('connection_type', 'not set'),
'qbd_connection': session.get('qbd_connection', 'not set'),
'qbo_credentials': 'present' if session.get('qbo_credentials') else 'not set',
'session_keys': list(session.keys())
})
# =============================================================================
# Routes - QuickBooks Desktop Connection
# =============================================================================
@app.route('/api/qbd/status', methods=['GET'])
def qbd_status():
"""Check QuickBooks Desktop availability and status."""
try:
status = check_qbd_availability()
# Also check current connection
qbd_data = session.get('qbd_connection', {})
status['connected'] = qbd_data.get('connected', False)
status['company_name'] = qbd_data.get('company_name', '')
status['connection_type'] = get_connection_type()
return jsonify({'success': True, **status})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/qbd/connect', methods=['POST'])
def qbd_connect():
"""Connect to QuickBooks Desktop using subprocess to avoid COM threading issues."""
import subprocess
import json
try:
log_action("CREATE", "Attempting to connect to QuickBooks Desktop (via subprocess)")
# Create a small Python script to run the connection in a separate process
# This avoids all Flask threading/COM issues
connect_script = '''
import sys
import json
try:
import pythoncom
from win32com.client import Dispatch
pythoncom.CoInitialize()
rp = Dispatch("QBXMLRP2.RequestProcessor")
rp.OpenConnection2("", "QBO Excel Sync", 1)
ticket = rp.BeginSession("", 0)
# Query company name
request = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<CompanyQueryRq></CompanyQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
response = rp.ProcessRequest(ticket, request)
# Parse company name
import xml.etree.ElementTree as ET
root = ET.fromstring(response)
company_elem = root.find(".//CompanyName")
company_name = company_elem.text if company_elem is not None else "QuickBooks Desktop"
rp.EndSession(ticket)
rp.CloseConnection()
pythoncom.CoUninitialize()
print(json.dumps({"success": True, "company_name": company_name}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
'''
# Run the script in a separate Python process
result = subprocess.run(
[sys.executable, '-c', connect_script],
capture_output=True,
text=True,
timeout=60 # 60 second timeout
)
logger.info(f"Subprocess stdout: {result.stdout}")
logger.info(f"Subprocess stderr: {result.stderr}")
if result.returncode != 0:
error_msg = result.stderr or result.stdout or "Unknown error"
log_action("CREATE", f"QBD subprocess failed: {error_msg}", "error")
return jsonify({
'success': False,
'error': f"Connection failed: {error_msg}"
}), 400
# Parse the JSON output
try:
output = json.loads(result.stdout.strip())
except json.JSONDecodeError:
log_action("CREATE", f"QBD subprocess invalid output: {result.stdout}", "error")
return jsonify({
'success': False,
'error': f"Invalid response from connection script: {result.stdout}"
}), 400
if not output.get('success'):
error_msg = output.get('error', 'Unknown error')
log_action("CREATE", f"QBD connection failed: {error_msg}", "error")
return jsonify({
'success': False,
'error': error_msg
}), 400
company_name = output.get('company_name', 'QuickBooks Desktop')
# Store connection info in session
session['connection_type'] = 'qbd'
session['qbd_connection'] = {
'connected': True,
'company_name': company_name,
'company_file': '',
'application_name': 'QBO Excel Sync',
'connected_at': datetime.now().isoformat()
}
session.modified = True # Force session to be saved
log_action("CREATE", f"Connected to QuickBooks Desktop: {company_name}")
return jsonify({
'success': True,
'company_name': company_name,
'message': 'Connected to QuickBooks Desktop'
})
except subprocess.TimeoutExpired:
log_action("CREATE", "QBD connection timed out after 60 seconds", "error")
return jsonify({
'success': False,
'error': "Connection timed out. QuickBooks may be waiting for user input or not responding."
}), 400
except Exception as e:
import traceback
error_trace = traceback.format_exc()
logger.error(f"QBD connection failed: {str(e)}\nTraceback: {error_trace}")
log_action("CREATE", f"QBD connection failed: {str(e)}", "error")
return jsonify({
'success': False,
'error': str(e)
}), 400
@app.route('/api/qbd/debug', methods=['GET'])
def qbd_debug():
"""Debug endpoint to check QBD availability with detailed info."""
import sys
result = {
'platform': sys.platform,
'is_windows': sys.platform == 'win32',
'python_version': sys.version,
}
# Check pywin32
try:
import pythoncom
from win32com.client import Dispatch
result['pywin32_installed'] = True
result['pythoncom_available'] = True
except ImportError as e:
result['pywin32_installed'] = False
result['pywin32_error'] = str(e)
return jsonify(result)
# Try to create RequestProcessor
try:
pythoncom.CoInitialize()
result['com_initialized'] = True
except Exception as e:
result['com_initialized'] = False
result['com_error'] = str(e)
return jsonify(result)
try:
rp = Dispatch("QBXMLRP2.RequestProcessor")
result['request_processor_created'] = True
except Exception as e:
result['request_processor_created'] = False
result['request_processor_error'] = str(e)
try:
pythoncom.CoUninitialize()
except:
pass
return jsonify(result)
# Try OpenConnection2
try:
rp.OpenConnection2("", "QBD Debug Test", 1) # 1 = localQBD
result['connection_opened'] = True
except Exception as e:
result['connection_opened'] = False
result['connection_error'] = str(e)
try:
pythoncom.CoUninitialize()
except:
pass
return jsonify(result)
# Try BeginSession
try:
ticket = rp.BeginSession("", 0) # 0 = qbFileOpenDoNotCare
result['session_started'] = True
result['session_ticket'] = ticket[:20] + "..." if ticket else None
# End session
rp.EndSession(ticket)
result['session_ended'] = True
except Exception as e:
result['session_started'] = False
result['session_error'] = str(e)
# Close connection
try:
rp.CloseConnection()
result['connection_closed'] = True
except Exception as e:
result['connection_closed'] = False
result['close_error'] = str(e)
try:
pythoncom.CoUninitialize()
except:
pass
return jsonify(result)
@app.route('/api/qbd/disconnect', methods=['POST'])
def qbd_disconnect():
"""Disconnect from QuickBooks Desktop."""
try:
log_action("DELETE", "Disconnecting from QuickBooks Desktop")
# Clear QBD session data
session.pop('qbd_connection', None)
session['connection_type'] = 'qbo' # Reset to QBO mode
logger.info("Disconnected from QuickBooks Desktop")
return jsonify({'success': True})
except Exception as e:
log_action("DELETE", f"QBD disconnect failed: {str(e)}", "error")
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/qbd/test', methods=['GET'])
def qbd_test():
"""Test QuickBooks Desktop connection using subprocess."""
import subprocess
import json as json_module
try:
qbd_data = session.get('qbd_connection', {})
if not qbd_data.get('connected'):
return jsonify({
'success': False,
'connected': False,
'error': 'Not connected to QuickBooks Desktop'
})
# Run test in subprocess
test_script = '''
import sys
import json
try:
import pythoncom
from win32com.client import Dispatch
pythoncom.CoInitialize()
rp = Dispatch("QBXMLRP2.RequestProcessor")
rp.OpenConnection2("", "QBO Excel Sync", 1)
ticket = rp.BeginSession("", 0)
# Query company
company_request = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<CompanyQueryRq></CompanyQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
response = rp.ProcessRequest(ticket, company_request)
import xml.etree.ElementTree as ET
root = ET.fromstring(response)
company_elem = root.find(".//CompanyName")
company_name = company_elem.text if company_elem is not None else "QuickBooks Desktop"
# Query customers count
customer_request = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<CustomerQueryRq><MaxReturned>1000</MaxReturned></CustomerQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
response = rp.ProcessRequest(ticket, customer_request)
root = ET.fromstring(response)
customers = root.findall(".//CustomerRet")
# Query vendors count
vendor_request = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<VendorQueryRq><MaxReturned>1000</MaxReturned></VendorQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
response = rp.ProcessRequest(ticket, vendor_request)
root = ET.fromstring(response)
vendors = root.findall(".//VendorRet")
# Query accounts count
account_request = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<AccountQueryRq><MaxReturned>1000</MaxReturned></AccountQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
response = rp.ProcessRequest(ticket, account_request)
root = ET.fromstring(response)
accounts = root.findall(".//AccountRet")
rp.EndSession(ticket)
rp.CloseConnection()
pythoncom.CoUninitialize()
print(json.dumps({
"success": True,
"company_name": company_name,
"customers_count": len(customers),
"vendors_count": len(vendors),
"accounts_count": len(accounts)
}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
'''
result = subprocess.run(
[sys.executable, '-c', test_script],
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
session.pop('qbd_connection', None)
return jsonify({
'success': False,
'connected': False,
'error': result.stderr or result.stdout or "Test failed"
})
output = json_module.loads(result.stdout.strip())
if not output.get('success'):
session.pop('qbd_connection', None)
return jsonify({
'success': False,
'connected': False,
'error': output.get('error', 'Unknown error')
})
# Update session
session['qbd_connection']['company_name'] = output.get('company_name')
log_action("CREATE", f"QBD test successful: {output.get('customers_count')} customers")
return jsonify({
'success': True,
'connected': True,
'company_name': output.get('company_name'),
'customers_count': output.get('customers_count', 0),
'vendors_count': output.get('vendors_count', 0),
'accounts_count': output.get('accounts_count', 0)
})
except subprocess.TimeoutExpired:
session.pop('qbd_connection', None)
return jsonify({
'success': False,
'connected': False,
'error': 'Test timed out'
})
except Exception as e:
return jsonify({
'success': False,
'connected': False,
'error': str(e)
})
@app.route('/api/connection/switch', methods=['POST'])
def switch_connection():
"""Switch between QBO and QBD connection modes."""
try:
data = request.json or {}
new_type = data.get('type', 'qbo')
if new_type not in ['qbo', 'qbd']:
return jsonify({'success': False, 'error': 'Invalid connection type'}), 400
session['connection_type'] = new_type
log_action("EDIT", f"Switched connection type to {new_type.upper()}")
return jsonify({
'success': True,
'connection_type': new_type
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/connection/test', methods=['GET']) @app.route('/api/connection/test', methods=['GET'])
def test_connection(): def test_connection():
"""Test QBO connection.""" """Test QB connection (QBO or QBD based on current mode)."""
try: try:
connection_type = get_connection_type()
if connection_type == 'qbd':
# Test QBD connection
qbd_data = session.get('qbd_connection', {})
if not qbd_data.get('connected'):
return jsonify({'success': False, 'connected': False, 'error': 'Not connected'})
creds = QBDCredentials(
application_name=qbd_data.get('application_name', 'QBO Excel Sync'),
company_file=qbd_data.get('company_file', '')
)
client = QBDesktopClient(creds)
client.connect()
customers = client.get_customers()
vendors = client.get_vendors()
accounts = client.get_accounts()
company_name = client.company_name
client.disconnect()
log_action("CREATE", f"QBD connection test successful: {len(customers)} customers")
return jsonify({
'success': True,
'connected': True,
'connection_type': 'qbd',
'company_name': company_name,
'customers_count': len(customers),
'vendors_count': len(vendors),
'accounts_count': len(accounts)
})
else:
# Test QBO connection
client = get_qbo_client() client = get_qbo_client()
if not client or not client.is_authenticated: if not client or not client.is_authenticated:
@@ -405,11 +941,12 @@ def test_connection():
vendors = client.get_vendors() vendors = client.get_vendors()
accounts = client.get_accounts() accounts = client.get_accounts()
log_action("CREATE", f"Connection test successful: {len(customers)} customers, {len(vendors)} vendors, {len(accounts)} accounts") log_action("CREATE", f"QBO connection test successful: {len(customers)} customers")
return jsonify({ return jsonify({
'success': True, 'success': True,
'connected': True, 'connected': True,
'connection_type': 'qbo',
'company_name': company_info.get('CompanyName', 'Unknown'), 'company_name': company_info.get('CompanyName', 'Unknown'),
'customers_count': len(customers), 'customers_count': len(customers),
'vendors_count': len(vendors), 'vendors_count': len(vendors),
@@ -1456,8 +1993,10 @@ if __name__ == '__main__':
UPLOAD_FOLDER.mkdir(exist_ok=True) UPLOAD_FOLDER.mkdir(exist_ok=True)
# Run the app # Run the app
# NOTE: threaded=False is important for COM/QuickBooks Desktop compatibility
# COM objects don't work well with Flask's default multi-threaded mode
debug_mode = os.environ.get('FLASK_DEBUG', 'true').lower() == 'true' debug_mode = os.environ.get('FLASK_DEBUG', 'true').lower() == 'true'
port = int(os.environ.get('PORT', 9000)) port = int(os.environ.get('PORT', 9000))
logger.info(f"Starting QBO Excel Sync Web App on port {port} (debug={debug_mode})") logger.info(f"Starting QBO Excel Sync Web App on port {port} (debug={debug_mode})")
app.run(host='0.0.0.0', port=port, debug=debug_mode) app.run(host='0.0.0.0', port=port, debug=debug_mode, threaded=False)
+1
View File
@@ -2,3 +2,4 @@ Flask>=2.3.0
Flask-Session>=0.5.0 Flask-Session>=0.5.0
openpyxl>=3.1.0 openpyxl>=3.1.0
requests>=2.31.0 requests>=2.31.0
pywin32>=306; sys_platform == 'win32'
+758
View File
@@ -0,0 +1,758 @@
"""
QuickBooks Desktop API Client
Handles COM-based communication with QuickBooks Desktop via QBXMLRP2.
Requires pywin32 and QuickBooks Desktop SDK installed on Windows.
"""
import os
import sys
import logging
import xml.etree.ElementTree as ET
from typing import Optional, Dict, List, Any
from dataclasses import dataclass
from datetime import datetime
logger = logging.getLogger(__name__)
# Check if we're on Windows
IS_WINDOWS = sys.platform == 'win32'
if IS_WINDOWS:
try:
import pythoncom
from win32com.client import Dispatch
HAS_WIN32COM = True
except ImportError:
HAS_WIN32COM = False
logger.warning("pywin32 not installed. QuickBooks Desktop integration unavailable.")
else:
HAS_WIN32COM = False
logger.info("Not running on Windows. QuickBooks Desktop integration unavailable.")
# Connection type constants (from QBXMLRP2 SDK)
class QBConnectionType:
localQBD = 1 # Local QuickBooks Desktop
remoteQBD = 2 # Remote QuickBooks Desktop
localQBDLaunchUI = 3 # Launch QuickBooks if not running
# File mode constants
class QBFileMode:
qbFileOpenDoNotCare = 0 # Open any file
qbFileOpenSingleUser = 1 # Open in single-user mode
qbFileOpenMultiUser = 2 # Open in multi-user mode
@dataclass
class QBDCredentials:
"""QuickBooks Desktop connection settings."""
application_name: str = "QBO Excel Sync"
application_id: str = ""
company_file: str = "" # Empty string = currently open company file
connection_type: int = QBConnectionType.localQBD
file_mode: int = QBFileMode.qbFileOpenDoNotCare
class QBDesktopError(Exception):
"""QuickBooks Desktop specific error."""
def __init__(self, message: str, code: str = None, detail: str = None):
self.message = message
self.code = code
self.detail = detail
super().__init__(self.message)
class QBDesktopClient:
"""QuickBooks Desktop API Client using COM interface."""
QBXML_VERSION = "13.0"
COUNTRY = "US"
def __init__(self, credentials: QBDCredentials = None):
self.credentials = credentials or QBDCredentials()
self.request_processor = None
self.session_ticket = None
self._connected = False
self._company_name = None
@property
def is_available(self) -> bool:
"""Check if QBD integration is available on this system."""
return IS_WINDOWS and HAS_WIN32COM
@property
def is_connected(self) -> bool:
"""Check if connected to QuickBooks Desktop."""
return self._connected and self.session_ticket is not None
@property
def company_name(self) -> Optional[str]:
"""Get the connected company name."""
return self._company_name
def connect(self) -> bool:
"""Connect to QuickBooks Desktop."""
if not self.is_available:
raise QBDesktopError("QuickBooks Desktop integration not available on this system")
try:
# Initialize COM for this thread
# CRITICAL: Flask runs in multi-threaded mode, so we need proper COM initialization
# Use CoInitializeEx with COINIT_APARTMENTTHREADED for COM automation objects
try:
# First, try to uninitialize any previous COM state
try:
pythoncom.CoUninitialize()
except:
pass
# Initialize with single-threaded apartment (STA) - required for UI automation objects
pythoncom.CoInitializeEx(pythoncom.COINIT_APARTMENTTHREADED)
logger.info("COM initialized with COINIT_APARTMENTTHREADED")
except pythoncom.com_error as e:
# If already initialized, that's OK
if e.hresult == -2147417850: # RPC_E_CHANGED_MODE
logger.info("COM already initialized in different mode, continuing...")
else:
logger.warning(f"COM initialization warning: {e}")
except Exception as e:
logger.warning(f"COM initialization warning: {e}")
# Create the request processor using Dispatch (not DispatchEx)
# Dispatch works better with apartment-threaded COM objects
try:
self.request_processor = Dispatch("QBXMLRP2.RequestProcessor")
logger.info("QBXMLRP2.RequestProcessor created successfully")
except Exception as e:
logger.error(f"Failed to create RequestProcessor: {e}")
raise QBDesktopError(
f"Cannot create QuickBooks RequestProcessor. "
f"Make sure QuickBooks Desktop SDK is installed. Error: {str(e)}",
code="SDK_NOT_FOUND"
)
# Open connection
try:
self.request_processor.OpenConnection2(
self.credentials.application_id,
self.credentials.application_name,
self.credentials.connection_type
)
logger.info("OpenConnection2 successful")
except Exception as e:
logger.error(f"OpenConnection2 failed: {e}")
raise QBDesktopError(
f"Failed to open connection to QuickBooks. Error: {str(e)}",
code="CONNECTION_FAILED"
)
# Begin session
# NOTE: This may trigger an authorization dialog in QuickBooks Desktop
# The user must click "Yes, always" in QuickBooks for this to succeed
logger.info("Attempting BeginSession - check QuickBooks for authorization dialog...")
try:
self.session_ticket = self.request_processor.BeginSession(
self.credentials.company_file,
self.credentials.file_mode
)
logger.info(f"BeginSession successful, ticket: {self.session_ticket[:20] if self.session_ticket else 'None'}...")
except Exception as e:
error_msg = str(e)
logger.error(f"BeginSession failed: {e}")
# Try to close the connection
try:
self.request_processor.CloseConnection()
except:
pass
# Parse common session errors
if "80040408" in error_msg:
raise QBDesktopError(
"QuickBooks is not running or no company file is open. "
"Please open QuickBooks Desktop with a company file first.",
code="QB_NOT_RUNNING"
)
elif "80040416" in error_msg or "80040417" in error_msg:
raise QBDesktopError(
"Access denied. When QuickBooks prompts you to allow access, "
"please click 'Yes, always allow access'. You may need to "
"run QuickBooks as Administrator.",
code="ACCESS_DENIED"
)
elif "80040422" in error_msg:
raise QBDesktopError(
"No company file is open in QuickBooks. "
"Please open a company file and try again.",
code="NO_COMPANY_FILE"
)
elif "80040423" in error_msg:
raise QBDesktopError(
"The company file is in use in single-user mode. "
"Switch to multi-user mode or close other connections.",
code="SINGLE_USER_MODE"
)
else:
raise QBDesktopError(
f"Failed to start QuickBooks session: {error_msg}",
code="SESSION_FAILED"
)
self._connected = True
# Get company info
self._fetch_company_info()
logger.info(f"Connected to QuickBooks Desktop: {self._company_name}")
return True
except QBDesktopError:
self._connected = False
raise
except Exception as e:
self._connected = False
error_msg = str(e)
logger.error(f"Unexpected error connecting to QBD: {error_msg}")
# Parse common errors
if "80040408" in error_msg:
raise QBDesktopError(
"QuickBooks is not running. Please open QuickBooks Desktop first.",
code="QB_NOT_RUNNING"
)
elif "80040416" in error_msg:
raise QBDesktopError(
"Access denied. Please authorize this application in QuickBooks.",
code="ACCESS_DENIED"
)
elif "80040422" in error_msg:
raise QBDesktopError(
"No company file is open in QuickBooks.",
code="NO_COMPANY_FILE"
)
else:
raise QBDesktopError(f"Failed to connect to QuickBooks Desktop: {error_msg}")
def disconnect(self) -> bool:
"""Disconnect from QuickBooks Desktop."""
try:
if self.session_ticket:
self.request_processor.EndSession(self.session_ticket)
self.session_ticket = None
if self.request_processor:
self.request_processor.CloseConnection()
self.request_processor = None
self._connected = False
self._company_name = None
# Uninitialize COM
pythoncom.CoUninitialize()
logger.info("Disconnected from QuickBooks Desktop")
return True
except Exception as e:
logger.error(f"Error disconnecting from QuickBooks Desktop: {e}")
return False
def _fetch_company_info(self):
"""Fetch company information from QuickBooks."""
try:
response = self._send_request("CompanyQuery", {})
if response:
company = response.get("CompanyRet", {})
self._company_name = company.get("CompanyName", "Unknown Company")
except Exception as e:
logger.warning(f"Could not fetch company info: {e}")
self._company_name = "Connected"
def _build_qbxml_request(self, request_type: str, request_data: Dict = None) -> str:
"""Build a QBXML request string."""
request_data = request_data or {}
# Build the request XML
xml_parts = [
'<?xml version="1.0" encoding="utf-8"?>',
'<?qbxml version="{}">'.format(self.QBXML_VERSION),
'<QBXML>',
'<QBXMLMsgsRq onError="stopOnError">'
]
# Add the specific request
xml_parts.append(f'<{request_type}Rq>')
if request_type.endswith("Query"):
# Query request
xml_parts.append(self._dict_to_xml(request_data))
elif request_type.endswith("Add"):
# Add request
base_type = request_type[:-3] # Remove "Add"
xml_parts.append(f'<{base_type}Add>')
xml_parts.append(self._dict_to_xml(request_data))
xml_parts.append(f'</{base_type}Add>')
elif request_type.endswith("Mod"):
# Modify request
base_type = request_type[:-3] # Remove "Mod"
xml_parts.append(f'<{base_type}Mod>')
xml_parts.append(self._dict_to_xml(request_data))
xml_parts.append(f'</{base_type}Mod>')
else:
xml_parts.append(self._dict_to_xml(request_data))
xml_parts.append(f'</{request_type}Rq>')
xml_parts.append('</QBXMLMsgsRq>')
xml_parts.append('</QBXML>')
return ''.join(xml_parts)
def _dict_to_xml(self, data: Dict, indent: int = 0) -> str:
"""Convert a dictionary to XML string."""
xml_parts = []
for key, value in data.items():
if value is None:
continue
elif isinstance(value, dict):
xml_parts.append(f'<{key}>')
xml_parts.append(self._dict_to_xml(value, indent + 1))
xml_parts.append(f'</{key}>')
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
xml_parts.append(f'<{key}>')
xml_parts.append(self._dict_to_xml(item, indent + 1))
xml_parts.append(f'</{key}>')
else:
xml_parts.append(f'<{key}>{self._escape_xml(str(item))}</{key}>')
else:
xml_parts.append(f'<{key}>{self._escape_xml(str(value))}</{key}>')
return ''.join(xml_parts)
def _escape_xml(self, text: str) -> str:
"""Escape special XML characters."""
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&apos;'))
def _send_request(self, request_type: str, request_data: Dict = None) -> Dict:
"""Send a request to QuickBooks and parse the response."""
if not self.is_connected:
raise QBDesktopError("Not connected to QuickBooks Desktop")
request_xml = self._build_qbxml_request(request_type, request_data)
logger.debug(f"Sending QBXML request: {request_type}")
logger.debug(f"Request XML: {request_xml[:500]}...")
try:
response_xml = self.request_processor.ProcessRequest(
self.session_ticket,
request_xml
)
logger.debug(f"Response XML: {response_xml[:500]}...")
return self._parse_response(response_xml, request_type)
except Exception as e:
raise QBDesktopError(f"Request failed: {str(e)}")
def _parse_response(self, response_xml: str, request_type: str) -> Dict:
"""Parse QBXML response into a dictionary."""
try:
root = ET.fromstring(response_xml)
# Find the response element
msgs_rs = root.find(".//QBXMLMsgsRs")
if msgs_rs is None:
raise QBDesktopError("Invalid QBXML response")
# Get the specific response
response_tag = f"{request_type}Rs"
response_elem = msgs_rs.find(response_tag)
if response_elem is None:
raise QBDesktopError(f"No response found for {request_type}")
# Check status
status_code = response_elem.get("statusCode", "0")
status_message = response_elem.get("statusMessage", "")
if status_code != "0":
raise QBDesktopError(
f"QuickBooks error: {status_message}",
code=status_code,
detail=status_message
)
# Parse response data
return self._xml_to_dict(response_elem)
except ET.ParseError as e:
raise QBDesktopError(f"Failed to parse response XML: {e}")
def _xml_to_dict(self, element: ET.Element) -> Dict:
"""Convert XML element to dictionary."""
result = {}
for child in element:
if len(child) > 0:
# Has children - recurse
child_dict = self._xml_to_dict(child)
if child.tag in result:
# Multiple elements with same tag - make a list
if not isinstance(result[child.tag], list):
result[child.tag] = [result[child.tag]]
result[child.tag].append(child_dict)
else:
result[child.tag] = child_dict
else:
# Leaf node
if child.tag in result:
if not isinstance(result[child.tag], list):
result[child.tag] = [result[child.tag]]
result[child.tag].append(child.text)
else:
result[child.tag] = child.text
return result
# ==========================================================================
# Query Methods
# ==========================================================================
def get_customers(self, max_results: int = 1000) -> List[Dict]:
"""Get all customers."""
response = self._send_request("CustomerQuery", {
"MaxReturned": str(max_results)
})
customers = response.get("CustomerRet", [])
if isinstance(customers, dict):
customers = [customers]
return customers
def get_vendors(self, max_results: int = 1000) -> List[Dict]:
"""Get all vendors."""
response = self._send_request("VendorQuery", {
"MaxReturned": str(max_results)
})
vendors = response.get("VendorRet", [])
if isinstance(vendors, dict):
vendors = [vendors]
return vendors
def get_accounts(self, max_results: int = 1000) -> List[Dict]:
"""Get all accounts."""
response = self._send_request("AccountQuery", {
"MaxReturned": str(max_results)
})
accounts = response.get("AccountRet", [])
if isinstance(accounts, dict):
accounts = [accounts]
return accounts
def get_items(self, max_results: int = 1000) -> List[Dict]:
"""Get all items/products."""
response = self._send_request("ItemQuery", {
"MaxReturned": str(max_results)
})
# Items can be different types
items = []
for item_type in ["ItemServiceRet", "ItemInventoryRet", "ItemNonInventoryRet",
"ItemOtherChargeRet", "ItemGroupRet", "ItemDiscountRet"]:
type_items = response.get(item_type, [])
if isinstance(type_items, dict):
type_items = [type_items]
items.extend(type_items)
return items
def get_employees(self, max_results: int = 1000) -> List[Dict]:
"""Get all employees."""
response = self._send_request("EmployeeQuery", {
"MaxReturned": str(max_results)
})
employees = response.get("EmployeeRet", [])
if isinstance(employees, dict):
employees = [employees]
return employees
# ==========================================================================
# Create Methods
# ==========================================================================
def create_check(self, data: Dict) -> Dict:
"""Create a check in QuickBooks Desktop."""
# Build the check request
check_data = {
"AccountRef": data.get("AccountRef"),
"PayeeEntityRef": data.get("EntityRef"),
"TxnDate": data.get("TxnDate"),
"RefNumber": data.get("DocNumber"),
"Memo": data.get("PrivateNote", ""),
}
# Add expense lines
if "Line" in data:
expense_lines = []
for line in data["Line"]:
if line.get("DetailType") == "AccountBasedExpenseLineDetail":
detail = line.get("AccountBasedExpenseLineDetail", {})
expense_lines.append({
"AccountRef": detail.get("AccountRef"),
"Amount": line.get("Amount"),
"Memo": line.get("Description", "")
})
if expense_lines:
check_data["ExpenseLineAdd"] = expense_lines
response = self._send_request("CheckAdd", check_data)
return response.get("CheckRet", {})
def create_invoice(self, data: Dict) -> Dict:
"""Create an invoice in QuickBooks Desktop."""
invoice_data = {
"CustomerRef": data.get("CustomerRef"),
"TxnDate": data.get("TxnDate"),
"RefNumber": data.get("DocNumber"),
"DueDate": data.get("DueDate"),
"Memo": data.get("PrivateNote", ""),
}
# Add invoice lines
if "Line" in data:
invoice_lines = []
for line in data["Line"]:
if line.get("DetailType") == "SalesItemLineDetail":
detail = line.get("SalesItemLineDetail", {})
invoice_lines.append({
"ItemRef": detail.get("ItemRef"),
"Quantity": detail.get("Qty"),
"Rate": detail.get("UnitPrice"),
"Amount": line.get("Amount"),
"Desc": line.get("Description", "")
})
if invoice_lines:
invoice_data["InvoiceLineAdd"] = invoice_lines
response = self._send_request("InvoiceAdd", invoice_data)
return response.get("InvoiceRet", {})
def create_bill(self, data: Dict) -> Dict:
"""Create a bill in QuickBooks Desktop."""
bill_data = {
"VendorRef": data.get("VendorRef"),
"TxnDate": data.get("TxnDate"),
"RefNumber": data.get("DocNumber"),
"DueDate": data.get("DueDate"),
"Memo": data.get("PrivateNote", ""),
}
# Add expense lines
if "Line" in data:
expense_lines = []
for line in data["Line"]:
if line.get("DetailType") == "AccountBasedExpenseLineDetail":
detail = line.get("AccountBasedExpenseLineDetail", {})
expense_lines.append({
"AccountRef": detail.get("AccountRef"),
"Amount": line.get("Amount"),
"Memo": line.get("Description", "")
})
if expense_lines:
bill_data["ExpenseLineAdd"] = expense_lines
response = self._send_request("BillAdd", bill_data)
return response.get("BillRet", {})
def create_customer(self, data: Dict) -> Dict:
"""Create a customer in QuickBooks Desktop."""
customer_data = {
"Name": data.get("DisplayName"),
"CompanyName": data.get("CompanyName"),
"FirstName": data.get("GivenName"),
"LastName": data.get("FamilyName"),
"Phone": data.get("PrimaryPhone", {}).get("FreeFormNumber") if isinstance(data.get("PrimaryPhone"), dict) else None,
"Email": data.get("PrimaryEmailAddr", {}).get("Address") if isinstance(data.get("PrimaryEmailAddr"), dict) else None,
}
# Add billing address
if "BillAddr" in data:
addr = data["BillAddr"]
customer_data["BillAddress"] = {
"Addr1": addr.get("Line1"),
"City": addr.get("City"),
"State": addr.get("CountrySubDivisionCode"),
"PostalCode": addr.get("PostalCode"),
"Country": addr.get("Country")
}
response = self._send_request("CustomerAdd", customer_data)
return response.get("CustomerRet", {})
def create_vendor(self, data: Dict) -> Dict:
"""Create a vendor in QuickBooks Desktop."""
vendor_data = {
"Name": data.get("DisplayName"),
"CompanyName": data.get("CompanyName"),
"FirstName": data.get("GivenName"),
"LastName": data.get("FamilyName"),
"Phone": data.get("PrimaryPhone", {}).get("FreeFormNumber") if isinstance(data.get("PrimaryPhone"), dict) else None,
"Email": data.get("PrimaryEmailAddr", {}).get("Address") if isinstance(data.get("PrimaryEmailAddr"), dict) else None,
}
# Add address
if "BillAddr" in data:
addr = data["BillAddr"]
vendor_data["VendorAddress"] = {
"Addr1": addr.get("Line1"),
"City": addr.get("City"),
"State": addr.get("CountrySubDivisionCode"),
"PostalCode": addr.get("PostalCode"),
"Country": addr.get("Country")
}
response = self._send_request("VendorAdd", vendor_data)
return response.get("VendorRet", {})
def create_account(self, data: Dict) -> Dict:
"""Create an account in QuickBooks Desktop."""
# Map QBO account types to QBD account types
account_type_map = {
"Bank": "Bank",
"Accounts Receivable": "AccountsReceivable",
"Other Current Asset": "OtherCurrentAsset",
"Fixed Asset": "FixedAsset",
"Other Asset": "OtherAsset",
"Accounts Payable": "AccountsPayable",
"Credit Card": "CreditCard",
"Other Current Liability": "OtherCurrentLiability",
"Long Term Liability": "LongTermLiability",
"Equity": "Equity",
"Income": "Income",
"Cost of Goods Sold": "CostOfGoodsSold",
"Expense": "Expense",
"Other Income": "OtherIncome",
"Other Expense": "OtherExpense"
}
qbo_type = data.get("AccountType", "Expense")
qbd_type = account_type_map.get(qbo_type, "Expense")
account_data = {
"Name": data.get("Name"),
"AccountType": qbd_type,
"Desc": data.get("Description"),
"AccountNumber": data.get("AcctNum"),
}
response = self._send_request("AccountAdd", account_data)
return response.get("AccountRet", {})
def __enter__(self):
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.disconnect()
return False
def check_qbd_availability() -> Dict[str, Any]:
"""Check if QuickBooks Desktop integration is available."""
result = {
"available": False,
"is_windows": IS_WINDOWS,
"has_pywin32": HAS_WIN32COM,
"qb_running": False,
"sdk_installed": False,
"error": None,
"details": ""
}
if not IS_WINDOWS:
result["error"] = "QuickBooks Desktop is only available on Windows"
result["details"] = "This feature requires Windows operating system"
return result
if not HAS_WIN32COM:
result["error"] = "pywin32 is not installed"
result["details"] = "Run: pip install pywin32"
return result
# Try to create the RequestProcessor to check if SDK is installed
rp = None
try:
pythoncom.CoInitialize()
# Try to create RequestProcessor
try:
rp = Dispatch("QBXMLRP2.RequestProcessor")
result["sdk_installed"] = True
result["available"] = True
logger.info("QBXMLRP2.RequestProcessor created successfully in availability check")
except Exception as e:
error_msg = str(e)
logger.warning(f"Failed to create RequestProcessor: {error_msg}")
if "80040154" in error_msg or "Class not registered" in error_msg:
result["error"] = "QuickBooks Desktop SDK not installed"
result["details"] = (
"The QBXMLRP2 component is not registered. "
"This usually means QuickBooks Desktop is not installed, "
"or the SDK components are missing. "
"Try repairing your QuickBooks installation."
)
else:
result["error"] = f"Cannot create QuickBooks interface: {error_msg}"
return result
# Try to open a connection to see if QB is running
try:
rp.OpenConnection2("", "QBD Availability Check", QBConnectionType.localQBD)
result["qb_running"] = True
logger.info("OpenConnection2 successful - QuickBooks is running")
# Close the connection
try:
rp.CloseConnection()
except:
pass
except Exception as e:
error_msg = str(e)
logger.info(f"OpenConnection2 check result: {error_msg}")
# QB SDK is available but QB might not be running
if "80040408" in error_msg:
result["qb_running"] = False
result["error"] = "QuickBooks Desktop is not running"
result["details"] = "Please open QuickBooks Desktop with a company file"
elif "80040402" in error_msg:
# This can happen when QB is running but locked
result["qb_running"] = True
result["error"] = "QuickBooks is busy or locked"
result["details"] = "QuickBooks may be showing a dialog. Please check QuickBooks."
else:
# Unknown error but SDK is there
result["qb_running"] = False
result["error"] = f"QuickBooks not accessible: {error_msg}"
except Exception as e:
error_msg = str(e)
logger.error(f"Unexpected error in availability check: {error_msg}")
result["error"] = f"Error checking QuickBooks: {error_msg}"
finally:
try:
pythoncom.CoUninitialize()
except:
pass
return result
+583 -128
View File
@@ -6,10 +6,54 @@
<div class="connection-page"> <div class="connection-page">
<div class="page-header"> <div class="page-header">
<h2>QuickBooks Connection</h2> <h2>QuickBooks Connection</h2>
<p>Connect to your QuickBooks Online account to start importing data.</p> <p>Connect to QuickBooks Online or QuickBooks Desktop to start importing data.</p>
</div> </div>
<div class="connection-layout"> <div class="connection-layout">
<!-- Connection Type Selector -->
<section class="card">
<div class="card-header">
<h3>Connection Type</h3>
</div>
<div class="card-body">
<div class="connection-type-selector">
<label class="connection-type-option {% if connection_type != 'qbd' %}active{% endif %}">
<input type="radio" name="connection_type" value="qbo" {% if connection_type != 'qbd' %}checked{% endif %}>
<div class="option-content">
<div class="option-icon qbo-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M8 12l2 2 4-4"/>
</svg>
</div>
<div class="option-text">
<strong>QuickBooks Online</strong>
<span>Cloud-based, OAuth authentication</span>
</div>
</div>
</label>
<label class="connection-type-option {% if connection_type == 'qbd' %}active{% endif %} {% if not qbd_available %}disabled{% endif %}">
<input type="radio" name="connection_type" value="qbd" {% if connection_type == 'qbd' %}checked{% endif %} {% if not qbd_available %}disabled{% endif %}>
<div class="option-content">
<div class="option-icon qbd-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="option-text">
<strong>QuickBooks Desktop</strong>
<span>{% if qbd_available %}Local installation, direct COM connection{% else %}Windows only, requires QuickBooks Desktop{% endif %}</span>
</div>
</div>
</label>
</div>
</div>
</section>
<!-- QBO Section -->
<div id="qbo-section" class="{% if connection_type == 'qbd' %}hidden{% endif %}">
<!-- Credentials Section --> <!-- Credentials Section -->
<section class="card collapsible"> <section class="card collapsible">
<div class="card-header" onclick="toggleSection(this)"> <div class="card-header" onclick="toggleSection(this)">
@@ -64,20 +108,15 @@
</div> </div>
</section> </section>
<!-- Connection Status Section --> <!-- QBO Connection Status Section -->
<section class="card collapsible"> <section class="card">
<div class="card-header" onclick="toggleSection(this)"> <div class="card-header">
<h3>Connection Status</h3> <h3>QuickBooks Online Status</h3>
<span class="collapse-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</div> </div>
<div class="card-body collapsible-content"> <div class="card-body">
<div class="connection-status-display {% if is_connected %}connected{% else %}disconnected{% endif %}"> <div class="connection-status-display {% if is_connected and connection_type != 'qbd' %}connected{% else %}disconnected{% endif %}">
<div class="status-icon"> <div class="status-icon">
{% if is_connected %} {% if is_connected and connection_type != 'qbd' %}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/> <polyline points="22 4 12 14.01 9 11.01"/>
@@ -91,23 +130,23 @@
{% endif %} {% endif %}
</div> </div>
<div class="status-details"> <div class="status-details">
<h4>{% if is_connected %}Connected{% else %}Not Connected{% endif %}</h4> <h4>{% if is_connected and connection_type != 'qbd' %}Connected{% else %}Not Connected{% endif %}</h4>
{% if is_connected and company_name %} {% if is_connected and company_name and connection_type != 'qbd' %}
<p class="company-name">{{ company_name }}</p> <p class="company-name">{{ company_name }}</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="connection-actions"> <div class="connection-actions">
{% if is_connected %} {% if is_connected and connection_type != 'qbd' %}
<button type="button" id="test-connection" class="btn btn-secondary"> <button type="button" id="test-connection-qbo" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/> <polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg> </svg>
Test Connection Test Connection
</button> </button>
<button type="button" id="disconnect" class="btn btn-danger"> <button type="button" id="disconnect-qbo" class="btn btn-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/> <path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/> <line x1="12" y1="2" x2="12" y2="12"/>
@@ -120,7 +159,7 @@
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg> </svg>
Connect to QuickBooks Connect to QuickBooks Online
</button> </button>
{% endif %} {% endif %}
</div> </div>
@@ -175,139 +214,440 @@
</form> </form>
</div> </div>
</section> </section>
</div>
<!-- Help Section --> <!-- QBD Section -->
<section class="card help-card collapsible collapsed"> <div id="qbd-section" class="{% if connection_type != 'qbd' %}hidden{% endif %}">
<div class="card-header" onclick="toggleSection(this)"> <section class="card">
<h3>Setup Instructions</h3> <div class="card-header">
<span class="collapse-icon"> <h3>QuickBooks Desktop Connection</h3>
</div>
<div class="card-body">
{% if not qbd_available %}
<div class="alert alert-warning">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg> </svg>
<div>
<strong>QuickBooks Desktop not available</strong>
<p>QuickBooks Desktop integration requires:</p>
<ul>
<li>Windows operating system</li>
<li>QuickBooks Desktop installed and running</li>
<li>pywin32 Python package (<code>pip install pywin32</code>)</li>
</ul>
</div>
</div>
{% else %}
<div class="qbd-info">
<div class="info-item">
<span class="info-label">Status:</span>
<span class="info-value" id="qbd-status">
{% if qbd_running %}
<span class="status-badge success">QuickBooks is running</span>
{% else %}
<span class="status-badge warning">QuickBooks not detected</span>
{% endif %}
</span> </span>
</div> </div>
<div class="card-body collapsible-content">
<div class="help-section">
<h4>Option 1: OAuth Flow (Recommended)</h4>
<ol>
<li>Go to <a href="https://developer.intuit.com" target="_blank">developer.intuit.com</a></li>
<li>Create or select your app → "Keys & OAuth" section</li>
<li>Add redirect URI: <code>http://localhost:5000/callback</code></li>
<li>Copy your Client ID and Client Secret</li>
<li>Save credentials above, then click "Connect to QuickBooks"</li>
</ol>
</div> </div>
<div class="help-section"> <div class="connection-status-display {% if is_connected and connection_type == 'qbd' %}connected{% else %}disconnected{% endif %}" id="qbd-connection-status">
<h4>Option 2: Manual Token Entry</h4> <div class="status-icon">
<ol> {% if is_connected and connection_type == 'qbd' %}
<li>In the developer portal, click "OAuth 2.0 Playground"</li> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<li>Authorize your sandbox/production company</li> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<li>Copy the Access Token, Refresh Token, and Realm ID</li> <polyline points="22 4 12 14.01 9 11.01"/>
<li>Paste them in the "Manual Token Entry" section above</li> </svg>
</ol> {% else %}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{% endif %}
</div>
<div class="status-details">
<h4 id="qbd-status-text">{% if is_connected and connection_type == 'qbd' %}Connected{% else %}Not Connected{% endif %}</h4>
{% if is_connected and company_name and connection_type == 'qbd' %}
<p class="company-name" id="qbd-company-name">{{ company_name }}</p>
{% else %}
<p class="company-name" id="qbd-company-name"></p>
{% endif %}
</div>
</div> </div>
<div class="help-note"> <div class="qbd-instructions">
<strong>Note:</strong> Access tokens expire after 1 hour. The app will automatically refresh using the refresh token. <h4>How to connect:</h4>
<ol>
<li><strong>Open QuickBooks Desktop</strong> on this computer (run as Administrator if needed)</li>
<li><strong>Open a company file</strong> - a company must be open, not just QuickBooks</li>
<li><strong>IMPORTANT: Check Integrated Applications setting</strong>
<ul>
<li>Go to <strong>Edit → Preferences → Integrated Applications</strong></li>
<li>Click <strong>Company Preferences</strong> tab</li>
<li>Make sure <strong>"Don't allow any applications..."</strong> is <strong>UNCHECKED</strong></li>
</ul>
</li>
<li>Click <strong>"Connect to QuickBooks Desktop"</strong> below</li>
<li>When QuickBooks shows an authorization dialog, click <strong>"Yes, always allow access"</strong></li>
</ol>
<div class="qbd-troubleshooting">
<h5>Troubleshooting:</h5>
<ul>
<li><strong>Connection hangs?</strong> Check Edit → Preferences → Integrated Applications → Company Preferences → make sure "Don't allow any applications to access this company file" is UNCHECKED</li>
<li>Try switching to <strong>Single-user mode</strong> (File → Switch to Single-user Mode)</li>
<li>Check for hidden QuickBooks dialogs (Alt+Tab or check taskbar)</li>
<li>Run QuickBooks as Administrator</li>
<li>Make sure you're logged into a company file (not the "No Company Open" screen)</li>
</ul>
</div> </div>
</div> </div>
<div class="qbd-error-display" id="qbd-error-display" style="display: none;">
<div class="alert alert-danger">
<strong>Connection Error:</strong>
<p id="qbd-error-message"></p>
<small id="qbd-error-details"></small>
</div>
</div>
<div class="connection-actions">
{% if is_connected and connection_type == 'qbd' %}
<button type="button" id="test-connection-qbd" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Test Connection
</button>
<button type="button" id="disconnect-qbd" class="btn btn-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
Disconnect
</button>
{% else %}
<button type="button" id="connect-qbd" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
Connect to QuickBooks Desktop
</button>
<button type="button" id="refresh-qbd-status" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh Status
</button>
{% endif %}
</div>
{% endif %}
</div>
</section> </section>
</div> </div>
</div>
</div> </div>
<style> <style>
/* Collapsible Sections */ /* Connection Type Selector */
.card.collapsible .card-header { .connection-type-selector {
cursor: pointer; display: flex;
user-select: none; gap: 16px;
margin-bottom: 8px;
}
.connection-type-option {
flex: 1;
cursor: pointer;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
transition: all 0.2s ease;
background: #fff;
}
.connection-type-option:hover:not(.disabled) {
border-color: #94a3b8;
}
.connection-type-option.active {
border-color: #2563eb;
background: #eff6ff;
}
.connection-type-option.disabled {
opacity: 0.6;
cursor: not-allowed;
background: #f8fafc;
}
.connection-type-option input[type="radio"] {
display: none;
}
.option-content {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 16px;
} }
.card.collapsible .card-header:hover { .option-icon {
background: var(--bg); width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
} }
.header-right { .option-icon.qbo-icon {
background: linear-gradient(135deg, #2CA01C 0%, #1a8a0e 100%);
color: white;
}
.option-icon.qbd-icon {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
}
.option-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.option-text strong {
font-size: 16px;
color: #1e293b;
}
.option-text span {
font-size: 13px;
color: #64748b;
}
/* QBD specific styles */
.qbd-info {
background: #f8fafc;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.info-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.collapse-icon { .info-label {
transition: transform 0.3s ease; font-weight: 500;
display: flex; color: #64748b;
}
.status-badge {
display: inline-flex;
align-items: center; align-items: center;
color: var(--text-muted); padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
} }
.card.collapsible.collapsed .collapse-icon { .status-badge.success {
transform: rotate(-90deg); background: #dcfce7;
color: #166534;
} }
.collapsible-content { .status-badge.warning {
max-height: 2000px; background: #fef3c7;
overflow: hidden; color: #92400e;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
opacity: 1;
} }
.card.collapsible.collapsed .collapsible-content { .qbd-instructions {
max-height: 0; background: #f0f9ff;
padding-top: 0; border: 1px solid #bae6fd;
padding-bottom: 0; border-radius: 8px;
opacity: 0; padding: 16px;
margin: 20px 0;
} }
/* Ensure card-body padding transitions smoothly */ .qbd-instructions h4 {
.card.collapsible .card-body { margin: 0 0 12px 0;
transition: padding 0.3s ease; color: #0369a1;
font-size: 14px;
} }
.card.collapsible.collapsed .card-body { .qbd-instructions ol {
padding: 0 20px; margin: 0;
padding-left: 20px;
color: #0c4a6e;
}
.qbd-instructions li {
margin-bottom: 8px;
font-size: 14px;
}
.qbd-instructions li:last-child {
margin-bottom: 0;
}
.alert {
display: flex;
gap: 12px;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert-warning {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
}
.alert svg {
flex-shrink: 0;
margin-top: 2px;
}
.alert strong {
display: block;
margin-bottom: 8px;
}
.alert p {
margin: 0 0 8px 0;
font-size: 14px;
}
.alert ul {
margin: 0;
padding-left: 20px;
}
.alert li {
font-size: 13px;
margin-bottom: 4px;
}
.alert code {
background: rgba(0,0,0,0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.alert-danger {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.alert-danger strong {
color: #7f1d1d;
}
.alert-danger p {
margin: 8px 0;
}
.alert-danger small {
display: block;
margin-top: 8px;
color: #b91c1c;
font-family: monospace;
font-size: 11px;
word-break: break-all;
}
.qbd-troubleshooting {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #bae6fd;
}
.qbd-troubleshooting h5 {
margin: 0 0 8px 0;
color: #0369a1;
font-size: 13px;
}
.qbd-troubleshooting ul {
margin: 0;
padding-left: 20px;
color: #0c4a6e;
}
.qbd-troubleshooting li {
font-size: 13px;
margin-bottom: 6px;
}
.qbd-error-display {
margin: 16px 0;
}
.hidden {
display: none !important;
}
@media (max-width: 768px) {
.connection-type-selector {
flex-direction: column;
}
} }
</style> </style>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
// Toggle collapsible section
function toggleSection(header) {
const card = header.closest('.card.collapsible');
card.classList.toggle('collapsed');
// Save state to localStorage
const sectionId = header.querySelector('h3').textContent.trim();
const isCollapsed = card.classList.contains('collapsed');
localStorage.setItem('section_' + sectionId, isCollapsed ? 'collapsed' : 'expanded');
}
// Global variable to track OAuth polling
let oauthPollTimer = null; let oauthPollTimer = null;
// Toggle collapsible sections
function toggleSection(header) {
const card = header.closest('.card');
const content = card.querySelector('.collapsible-content');
card.classList.toggle('collapsed');
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Check if we need to handle OAuth completion on page load // Connection type switching
const oauthComplete = localStorage.getItem('oauth_complete'); document.querySelectorAll('input[name="connection_type"]').forEach(radio => {
if (oauthComplete) { radio.addEventListener('change', async function() {
localStorage.removeItem('oauth_complete'); const type = this.value;
// Page was just reloaded after OAuth - show success message
showToast('Successfully connected to QuickBooks!', 'success');
}
// Restore section states from localStorage // Update UI
document.querySelectorAll('.card.collapsible').forEach(card => { document.querySelectorAll('.connection-type-option').forEach(opt => {
const sectionId = card.querySelector('.card-header h3').textContent.trim(); opt.classList.remove('active');
const savedState = localStorage.getItem('section_' + sectionId); });
this.closest('.connection-type-option').classList.add('active');
if (savedState === 'collapsed') { // Show/hide sections
card.classList.add('collapsed'); document.getElementById('qbo-section').classList.toggle('hidden', type === 'qbd');
} else if (savedState === 'expanded') { document.getElementById('qbd-section').classList.toggle('hidden', type !== 'qbd');
card.classList.remove('collapsed');
// Notify server
try {
await fetch('/api/connection/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: type })
});
} catch (error) {
console.error('Error switching connection type:', error);
} }
}); });
});
// Save credentials form // Save credentials form
document.getElementById('credentials-form').addEventListener('submit', async function(e) { document.getElementById('credentials-form').addEventListener('submit', async function(e) {
@@ -339,7 +679,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// Connect via OAuth // Connect via OAuth (QBO)
const connectBtn = document.getElementById('connect-oauth'); const connectBtn = document.getElementById('connect-oauth');
if (connectBtn) { if (connectBtn) {
connectBtn.addEventListener('click', async function() { connectBtn.addEventListener('click', async function() {
@@ -352,13 +692,8 @@ document.addEventListener('DOMContentLoaded', function() {
const result = await response.json(); const result = await response.json();
if (result.success && result.auth_url) { if (result.success && result.auth_url) {
// Clear any previous OAuth flag
localStorage.removeItem('oauth_complete'); localStorage.removeItem('oauth_complete');
// Open OAuth in new window
const oauthPopup = window.open(result.auth_url, 'QBO_OAuth', 'width=600,height=700'); const oauthPopup = window.open(result.auth_url, 'QBO_OAuth', 'width=600,height=700');
// Start polling for OAuth completion
startOAuthPolling(oauthPopup); startOAuthPolling(oauthPopup);
} else { } else {
showToast(result.error || 'Failed to start OAuth flow', 'error'); showToast(result.error || 'Failed to start OAuth flow', 'error');
@@ -369,10 +704,10 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Test connection // Test connection QBO
const testBtn = document.getElementById('test-connection'); const testBtnQbo = document.getElementById('test-connection-qbo');
if (testBtn) { if (testBtnQbo) {
testBtn.addEventListener('click', async function() { testBtnQbo.addEventListener('click', async function() {
try { try {
const response = await fetch('/api/connection/test'); const response = await fetch('/api/connection/test');
const result = await response.json(); const result = await response.json();
@@ -388,19 +723,14 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Disconnect // Disconnect QBO
const disconnectBtn = document.getElementById('disconnect'); const disconnectBtnQbo = document.getElementById('disconnect-qbo');
if (disconnectBtn) { if (disconnectBtnQbo) {
disconnectBtn.addEventListener('click', async function() { disconnectBtnQbo.addEventListener('click', async function() {
if (!confirm('Are you sure you want to disconnect from QuickBooks?')) { if (!confirm('Are you sure you want to disconnect from QuickBooks Online?')) return;
return;
}
try { try {
const response = await fetch('/api/disconnect', { const response = await fetch('/api/disconnect', { method: 'POST' });
method: 'POST'
});
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
@@ -415,6 +745,136 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Connect to QBD
const connectBtnQbd = document.getElementById('connect-qbd');
if (connectBtnQbd) {
connectBtnQbd.addEventListener('click', async function() {
this.disabled = true;
this.innerHTML = '<span class="spinner"></span> Connecting... (Check QuickBooks for authorization dialog)';
// Show a helpful message
showToast('Connecting... Please check QuickBooks Desktop for an authorization dialog and click "Yes, always allow access"', 'info', 10000);
// Hide previous error
const errorDisplay = document.getElementById('qbd-error-display');
if (errorDisplay) errorDisplay.style.display = 'none';
try {
const response = await fetch('/api/qbd/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const result = await response.json();
console.log('QBD Connect result:', result);
if (result.success) {
showToast(`Connected to ${result.company_name}`, 'success');
setTimeout(() => location.reload(), 1000);
} else {
// Show detailed error
const errorMsg = result.error || 'Failed to connect to QuickBooks Desktop';
const errorDetails = result.details || result.code || '';
showToast(errorMsg, 'error');
// Show in error display area
if (errorDisplay) {
document.getElementById('qbd-error-message').textContent = errorMsg;
document.getElementById('qbd-error-details').textContent = errorDetails;
errorDisplay.style.display = 'block';
}
// Log full response for debugging
console.error('QBD connection failed:', result);
if (result.traceback) {
console.error('Traceback:', result.traceback);
}
this.disabled = false;
this.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> Connect to QuickBooks Desktop';
}
} catch (error) {
console.error('QBD connection error:', error);
showToast('Error connecting: ' + error.message, 'error');
this.disabled = false;
this.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> Connect to QuickBooks Desktop';
}
});
}
// Test connection QBD
const testBtnQbd = document.getElementById('test-connection-qbd');
if (testBtnQbd) {
testBtnQbd.addEventListener('click', async function() {
try {
const response = await fetch('/api/qbd/test');
const result = await response.json();
if (result.success) {
showToast(`Connected to ${result.company_name}. Found ${result.customers_count} customers, ${result.vendors_count} vendors, ${result.accounts_count} accounts.`, 'success');
} else {
showToast(result.error || 'Connection test failed', 'error');
}
} catch (error) {
showToast('Error testing connection: ' + error.message, 'error');
}
});
}
// Disconnect QBD
const disconnectBtnQbd = document.getElementById('disconnect-qbd');
if (disconnectBtnQbd) {
disconnectBtnQbd.addEventListener('click', async function() {
if (!confirm('Are you sure you want to disconnect from QuickBooks Desktop?')) return;
try {
const response = await fetch('/api/qbd/disconnect', { method: 'POST' });
const result = await response.json();
if (result.success) {
showToast('Disconnected successfully', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast(result.error || 'Failed to disconnect', 'error');
}
} catch (error) {
showToast('Error disconnecting: ' + error.message, 'error');
}
});
}
// Refresh QBD status
const refreshBtnQbd = document.getElementById('refresh-qbd-status');
if (refreshBtnQbd) {
refreshBtnQbd.addEventListener('click', async function() {
try {
const response = await fetch('/api/qbd/status');
const result = await response.json();
if (result.success) {
const statusEl = document.querySelector('#qbd-status .status-badge');
const connectBtn = document.getElementById('connect-qbd');
if (result.qb_running) {
statusEl.className = 'status-badge success';
statusEl.textContent = 'QuickBooks is running';
if (connectBtn) connectBtn.disabled = false;
} else {
statusEl.className = 'status-badge warning';
statusEl.textContent = 'QuickBooks not detected';
if (connectBtn) connectBtn.disabled = true;
}
showToast('Status refreshed', 'info');
}
} catch (error) {
showToast('Error refreshing status: ' + error.message, 'error');
}
});
}
// Manual token form // Manual token form
document.getElementById('manual-token-form').addEventListener('submit', async function(e) { document.getElementById('manual-token-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
@@ -453,18 +913,16 @@ document.addEventListener('DOMContentLoaded', function() {
// Function to poll for OAuth completion // Function to poll for OAuth completion
function startOAuthPolling(popup) { function startOAuthPolling(popup) {
// Clear any existing timer
if (oauthPollTimer) { if (oauthPollTimer) {
clearInterval(oauthPollTimer); clearInterval(oauthPollTimer);
} }
let checkCount = 0; let checkCount = 0;
const maxChecks = 300; // 5 minutes max (300 * 1000ms) const maxChecks = 300;
oauthPollTimer = setInterval(async function() { oauthPollTimer = setInterval(async function() {
checkCount++; checkCount++;
// Check localStorage for OAuth completion signal
const oauthComplete = localStorage.getItem('oauth_complete'); const oauthComplete = localStorage.getItem('oauth_complete');
if (oauthComplete) { if (oauthComplete) {
clearInterval(oauthPollTimer); clearInterval(oauthPollTimer);
@@ -474,11 +932,9 @@ function startOAuthPolling(popup) {
return; return;
} }
// Check if popup is closed
if (popup && popup.closed) { if (popup && popup.closed) {
clearInterval(oauthPollTimer); clearInterval(oauthPollTimer);
// Wait a moment then check connection status
setTimeout(async function() { setTimeout(async function() {
try { try {
const response = await fetch('/api/connection/test'); const response = await fetch('/api/connection/test');
@@ -497,7 +953,6 @@ function startOAuthPolling(popup) {
return; return;
} }
// Stop polling after max time
if (checkCount >= maxChecks) { if (checkCount >= maxChecks) {
clearInterval(oauthPollTimer); clearInterval(oauthPollTimer);
showToast('OAuth timeout. Please try again.', 'warning'); showToast('OAuth timeout. Please try again.', 'warning');
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
QuickBooks Desktop Connection Test Script
Run this directly on your Windows machine to test the connection.
Usage:
python test_qbd_connection.py
"""
import sys
import time
print("=" * 60)
print("QuickBooks Desktop Connection Test")
print("=" * 60)
# Check platform
print(f"\n1. Platform: {sys.platform}")
if sys.platform != 'win32':
print(" ERROR: This script must be run on Windows!")
sys.exit(1)
print(" OK: Running on Windows")
# Check pywin32
print("\n2. Checking pywin32...")
try:
import pythoncom
from win32com.client import Dispatch
print(" OK: pywin32 is installed")
except ImportError as e:
print(f" ERROR: pywin32 not installed: {e}")
print(" Run: pip install pywin32")
sys.exit(1)
# Initialize COM
print("\n3. Initializing COM...")
try:
pythoncom.CoInitialize()
print(" OK: COM initialized")
except Exception as e:
print(f" ERROR: Failed to initialize COM: {e}")
sys.exit(1)
# Create RequestProcessor
print("\n4. Creating QBXMLRP2.RequestProcessor...")
try:
rp = Dispatch("QBXMLRP2.RequestProcessor")
print(" OK: RequestProcessor created")
except Exception as e:
print(f" ERROR: Failed to create RequestProcessor: {e}")
print("\n This usually means:")
print(" - QuickBooks Desktop is not installed")
print(" - QuickBooks SDK components are not registered")
print(" - Try: regsvr32 \"C:\\Program Files (x86)\\Common Files\\Intuit\\QuickBooks\\qbxmlrp2.dll\"")
pythoncom.CoUninitialize()
sys.exit(1)
# Open Connection
print("\n5. Opening connection (OpenConnection2)...")
try:
# Connection types:
# 1 = localQBD (local QuickBooks Desktop)
# 2 = remoteQBD (remote QuickBooks Desktop)
# 3 = localQBDLaunchUI (launch QB if not running)
rp.OpenConnection2("", "QBD Test Script", 1)
print(" OK: Connection opened")
except Exception as e:
print(f" ERROR: OpenConnection2 failed: {e}")
pythoncom.CoUninitialize()
sys.exit(1)
# Begin Session - THIS IS WHERE IT USUALLY HANGS
print("\n6. Beginning session (BeginSession)...")
print(" NOTE: If this hangs, check QuickBooks for an authorization dialog!")
print(" Also check: Edit > Preferences > Integrated Applications > Company Preferences")
print(" Make sure 'Don't allow any applications...' is UNCHECKED")
print("")
print(" Attempting BeginSession with timeout indicator...")
print(" (If you see dots appearing, the call is hanging)")
# Start time
start_time = time.time()
# We can't easily timeout a COM call, but we can show progress
import threading
stop_dots = False
def print_dots():
count = 0
while not stop_dots:
time.sleep(1)
count += 1
print(f" ... waiting {count}s", end='\r')
if count > 30:
print("\n WARNING: BeginSession is taking too long (>30s)")
print(" This usually means QuickBooks is waiting for user input")
print(" CHECK QUICKBOOKS FOR A DIALOG BOX!")
dot_thread = threading.Thread(target=print_dots)
dot_thread.daemon = True
dot_thread.start()
try:
# File modes:
# 0 = qbFileOpenDoNotCare
# 1 = qbFileOpenSingleUser
# 2 = qbFileOpenMultiUser
ticket = rp.BeginSession("", 0) # Empty string = currently open file
stop_dots = True
elapsed = time.time() - start_time
print(f"\n OK: Session started in {elapsed:.2f}s")
print(f" Session ticket: {ticket[:30]}...")
except Exception as e:
stop_dots = True
elapsed = time.time() - start_time
print(f"\n ERROR: BeginSession failed after {elapsed:.2f}s: {e}")
error_msg = str(e)
if "80040408" in error_msg:
print("\n DIAGNOSIS: QuickBooks is not running or no company file is open")
elif "80040416" in error_msg or "80040417" in error_msg:
print("\n DIAGNOSIS: Access denied - need to authorize in QuickBooks")
elif "80040422" in error_msg:
print("\n DIAGNOSIS: No company file is open")
elif "80040423" in error_msg:
print("\n DIAGNOSIS: Company file is in single-user mode by another user")
try:
rp.CloseConnection()
except:
pass
pythoncom.CoUninitialize()
sys.exit(1)
# If we got here, try a simple query
print("\n7. Testing with a simple Company query...")
try:
# Build a simple QBXML request
request = '''<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<CompanyQueryRq>
</CompanyQueryRq>
</QBXMLMsgsRq>
</QBXML>'''
response = rp.ProcessRequest(ticket, request)
print(" OK: Query executed successfully")
# Parse response to get company name
import xml.etree.ElementTree as ET
root = ET.fromstring(response)
company_name = root.find(".//CompanyName")
if company_name is not None:
print(f" Company Name: {company_name.text}")
except Exception as e:
print(f" ERROR: Query failed: {e}")
# End session
print("\n8. Ending session...")
try:
rp.EndSession(ticket)
print(" OK: Session ended")
except Exception as e:
print(f" WARNING: EndSession failed: {e}")
# Close connection
print("\n9. Closing connection...")
try:
rp.CloseConnection()
print(" OK: Connection closed")
except Exception as e:
print(f" WARNING: CloseConnection failed: {e}")
# Uninitialize COM
try:
pythoncom.CoUninitialize()
except:
pass
print("\n" + "=" * 60)
print("TEST COMPLETED SUCCESSFULLY!")
print("=" * 60)
print("\nQuickBooks Desktop connection is working properly.")
print("If the web app still doesn't work, the issue may be with")
print("Flask session handling or threading.")