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
+587 -48
View File
@@ -19,6 +19,7 @@ from werkzeug.utils import secure_filename
from src.config.settings import Settings, QBOCredentials, MappingTemplate, FieldMapping
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.import_processor import ImportProcessor, ImportResult, ImportStatus
from dataclasses import asdict
@@ -60,8 +61,17 @@ def allowed_file(filename):
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():
"""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
creds_data = session.get('qbo_credentials')
if creds_data:
@@ -88,24 +98,89 @@ def get_qbo_client():
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
def inject_common_variables():
"""Inject common variables into all templates."""
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
# Get connection type directly from session
connection_type = session.get('connection_type', 'qbo')
is_connected = False
company_name = ""
if is_connected:
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()
is_connected = client.is_authenticated if client else False
if is_connected:
try:
company_info = client.get_company_info()
company_name = company_info.get('CompanyName', '')
except Exception:
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:
company_info = client.get_company_info()
company_name = company_info.get('CompanyName', '')
except Exception:
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 {
'is_connected': is_connected,
'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():
"""Home page / Dashboard."""
log_action("PAGE_VIEW", "Dashboard")
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
company_name = ""
if is_connected:
try:
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}")
# Don't pass is_connected and company_name - let the context processor handle it
# This allows both QBO and QBD connection status to be shown correctly
return render_template('index.html',
is_connected=is_connected,
company_name=company_name,
environment=settings.qbo_environment)
@@ -159,21 +225,12 @@ def connection():
"""Connection management page."""
log_action("PAGE_VIEW", "Connection")
creds = settings.get_credentials()
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
company_name = ""
if is_connected:
try:
company_info = client.get_company_info()
company_name = company_info.get('CompanyName', '')
except Exception:
pass
# Don't pass is_connected and company_name - let the context processor handle it
# This allows QBD connection status to be shown correctly
return render_template('connection.html',
credentials=creds,
is_connected=is_connected,
company_name=company_name,
environment=settings.qbo_environment)
@@ -181,14 +238,12 @@ def connection():
def import_page():
"""Import page."""
log_action("PAGE_VIEW", "Import")
client = get_qbo_client()
is_connected = client.is_authenticated if client else False
# Get available templates
templates = settings.get_templates()
# Don't pass is_connected - let the context processor handle it
return render_template('import.html',
is_connected=is_connected,
templates=templates,
environment=settings.qbo_environment)
@@ -391,30 +446,512 @@ def disconnect():
return jsonify({'success': False, 'error': str(e)}), 400
@app.route('/api/connection/test', methods=['GET'])
def test_connection():
"""Test QBO connection."""
@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:
client = get_qbo_client()
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()
if not client or not client.is_authenticated:
return jsonify({'success': False, 'connected': False, 'error': 'Not connected'})
# 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()
company_info = client.get_company_info()
customers = client.get_customers()
vendors = client.get_vendors()
accounts = client.get_accounts()
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)")
log_action("CREATE", f"Connection test successful: {len(customers)} customers, {len(vendors)} vendors, {len(accounts)} accounts")
# 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': company_info.get('CompanyName', 'Unknown'),
'customers_count': len(customers),
'vendors_count': len(vendors),
'accounts_count': len(accounts)
'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'])
def test_connection():
"""Test QB connection (QBO or QBD based on current mode)."""
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()
if not client or not client.is_authenticated:
return jsonify({'success': False, 'connected': False, 'error': 'Not connected'})
company_info = client.get_company_info()
customers = client.get_customers()
vendors = client.get_vendors()
accounts = client.get_accounts()
log_action("CREATE", f"QBO connection test successful: {len(customers)} customers")
return jsonify({
'success': True,
'connected': True,
'connection_type': 'qbo',
'company_name': company_info.get('CompanyName', 'Unknown'),
'customers_count': len(customers),
'vendors_count': len(vendors),
'accounts_count': len(accounts)
})
except Exception as e:
log_action("CREATE", f"Connection test failed: {str(e)}", "error")
return jsonify({'success': False, 'connected': False, 'error': str(e)})
@@ -1456,8 +1993,10 @@ if __name__ == '__main__':
UPLOAD_FOLDER.mkdir(exist_ok=True)
# 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'
port = int(os.environ.get('PORT', 9000))
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)