Update with QB Desktop connection
This commit is contained in:
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -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('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
.replace("'", '''))
|
||||||
|
|
||||||
|
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
@@ -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');
|
||||||
|
|||||||
@@ -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.")
|
||||||
Reference in New Issue
Block a user