Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
335488223d | ||
|
|
a29135304d | ||
|
|
0f913ae3c9 | ||
|
|
34d73a6d2c | ||
|
|
2b4a485819 | ||
|
|
03bcff5ff2 | ||
|
|
dedb2db026 | ||
|
|
2f4326ec15 | ||
|
|
da41509840 | ||
|
|
c8211600ec |
@@ -242,3 +242,7 @@ User actions (create, edit, delete) are automatically logged with:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary - Internal Use Only
|
Proprietary - Internal Use Only
|
||||||
|
|
||||||
|
## Build Windows executable app command
|
||||||
|
|
||||||
|
pyinstaller .\build_exe.spec --clean --noconfirm
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"client_id": "", "client_secret": "", "redirect_uri": "http://localhost:5000/callback", "environment": "sandbox", "access_token": "", "refresh_token": "RT1-89-H0-17797182624khhi6gb525o89u1m5sf", "realm_id": "9341456380310090", "token_expiry": ""}
|
|
||||||
@@ -2,33 +2,12 @@
|
|||||||
"name": "LT Payroll",
|
"name": "LT Payroll",
|
||||||
"data_type": "check",
|
"data_type": "check",
|
||||||
"mappings": [
|
"mappings": [
|
||||||
{
|
|
||||||
"excel_column": "Payee",
|
|
||||||
"qbo_field": "EntityRef.value",
|
|
||||||
"transform": null,
|
|
||||||
"default_value": null,
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"excel_column": "Bank Acct",
|
"excel_column": "Bank Acct",
|
||||||
"qbo_field": "AccountRef.value",
|
"qbo_field": "AccountRef.value",
|
||||||
"transform": null,
|
"transform": null,
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"required": true
|
"required": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"excel_column": "Date",
|
|
||||||
"qbo_field": "TxnDate",
|
|
||||||
"transform": "date",
|
|
||||||
"default_value": null,
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"excel_column": "Amount",
|
|
||||||
"qbo_field": "Line.Amount",
|
|
||||||
"transform": "currency",
|
|
||||||
"default_value": null,
|
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"excel_column": "Number",
|
"excel_column": "Number",
|
||||||
@@ -37,6 +16,20 @@
|
|||||||
"default_value": null,
|
"default_value": null,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"excel_column": "Date",
|
||||||
|
"qbo_field": "TxnDate",
|
||||||
|
"transform": null,
|
||||||
|
"default_value": null,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"excel_column": "Payee",
|
||||||
|
"qbo_field": "EntityRef.value",
|
||||||
|
"transform": null,
|
||||||
|
"default_value": null,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"excel_column": "Posting Acct",
|
"excel_column": "Posting Acct",
|
||||||
"qbo_field": "Line.AccountBasedExpenseLineDetail.AccountRef.value",
|
"qbo_field": "Line.AccountBasedExpenseLineDetail.AccountRef.value",
|
||||||
@@ -45,8 +38,15 @@
|
|||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"excel_column": "Period Memo",
|
"excel_column": "Amount",
|
||||||
"qbo_field": "PrivateNote",
|
"qbo_field": "Line.Amount",
|
||||||
|
"transform": null,
|
||||||
|
"default_value": null,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"excel_column": "Period",
|
||||||
|
"qbo_field": "Line.Description",
|
||||||
"transform": null,
|
"transform": null,
|
||||||
"default_value": null,
|
"default_value": null,
|
||||||
"required": false
|
"required": false
|
||||||
@@ -59,6 +59,6 @@
|
|||||||
"required": false
|
"required": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"created_at": "2026-02-12T12:39:07.304264",
|
"created_at": "2026-02-16T20:16:04.745245",
|
||||||
"updated_at": "2026-02-12T12:39:07.304281"
|
"updated_at": "2026-02-16T20:16:04.745272"
|
||||||
}
|
}
|
||||||
+5407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
"""
|
||||||
|
QBO Excel Sync - OAuth Callback Server
|
||||||
|
|
||||||
|
This is a simple Flask server that handles OAuth callbacks from QuickBooks.
|
||||||
|
Host this on your own server (e.g., https://yourcompany.com/oauth/callback)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. Deploy this to your server
|
||||||
|
2. Set up SSL (required for QuickBooks production)
|
||||||
|
3. Register the callback URL in your QuickBooks app settings
|
||||||
|
4. Update the desktop app's redirect_uri to match
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
- PORT: Server port (default: 5000)
|
||||||
|
- SECRET_KEY: Flask secret key for sessions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, request, render_template_string, jsonify
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
|
||||||
|
|
||||||
|
# HTML template for displaying the authorization code
|
||||||
|
CALLBACK_TEMPLATE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>QBO Excel Sync - Authorization</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.success-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.error-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.code-section {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.code-label {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.code-box {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.copy-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
.realm-info {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.realm-info strong {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.instructions {
|
||||||
|
text-align: left;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.instructions ol {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #991b1b;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-icon">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<div class="error-message">
|
||||||
|
<strong>Error:</strong> {{ error }}<br>
|
||||||
|
{% if error_description %}
|
||||||
|
<strong>Details:</strong> {{ error_description }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1>Authorization Successful!</h1>
|
||||||
|
<p class="subtitle">Copy the information below and paste it into the QBO Excel Sync app.</p>
|
||||||
|
|
||||||
|
<div class="realm-info">
|
||||||
|
<strong>Company ID (Realm ID):</strong> {{ realm_id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-section">
|
||||||
|
<div class="code-label">Authorization Code</div>
|
||||||
|
<div class="code-box" id="authCode">{{ code }}</div>
|
||||||
|
<button class="copy-btn" onclick="copyCode()">
|
||||||
|
<span id="btnText">Copy Authorization Code</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<strong>Next Steps:</strong>
|
||||||
|
<ol>
|
||||||
|
<li>Go back to the <strong>QBO Excel Sync</strong> desktop application</li>
|
||||||
|
<li>Click <strong>"Enter Code Manually"</strong> button</li>
|
||||||
|
<li>Paste the <strong>Authorization Code</strong> and <strong>Company ID</strong></li>
|
||||||
|
<li>Click <strong>Connect</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
QBO Excel Sync © {{ year }} | You can close this window after copying the code.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCode() {
|
||||||
|
const code = document.getElementById('authCode').textContent;
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
const btn = document.querySelector('.copy-btn');
|
||||||
|
const btnText = document.getElementById('btnText');
|
||||||
|
btn.classList.add('copied');
|
||||||
|
btnText.textContent = '✓ Copied!';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
btnText.textContent = 'Copy Authorization Code';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'service': 'QBO Excel Sync OAuth Callback Server',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/oauth/callback')
|
||||||
|
def oauth_callback():
|
||||||
|
"""
|
||||||
|
Handle OAuth callback from QuickBooks.
|
||||||
|
|
||||||
|
QuickBooks redirects here with:
|
||||||
|
- code: Authorization code (on success)
|
||||||
|
- realmId: Company ID
|
||||||
|
- state: State parameter we sent
|
||||||
|
- error: Error code (on failure)
|
||||||
|
- error_description: Error details (on failure)
|
||||||
|
"""
|
||||||
|
# Get parameters
|
||||||
|
code = request.args.get('code')
|
||||||
|
realm_id = request.args.get('realmId')
|
||||||
|
state = request.args.get('state')
|
||||||
|
error = request.args.get('error')
|
||||||
|
error_description = request.args.get('error_description')
|
||||||
|
|
||||||
|
# Log the callback
|
||||||
|
logger.info(f"OAuth callback received - realm_id: {realm_id}, state: {state}, error: {error}")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logger.error(f"OAuth error: {error} - {error_description}")
|
||||||
|
return render_template_string(
|
||||||
|
CALLBACK_TEMPLATE,
|
||||||
|
error=error,
|
||||||
|
error_description=error_description,
|
||||||
|
year=datetime.now().year
|
||||||
|
)
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
logger.error("No authorization code received")
|
||||||
|
return render_template_string(
|
||||||
|
CALLBACK_TEMPLATE,
|
||||||
|
error="No authorization code received",
|
||||||
|
error_description="The authorization server did not return a code.",
|
||||||
|
year=datetime.now().year
|
||||||
|
)
|
||||||
|
|
||||||
|
# Success - display the code to the user
|
||||||
|
logger.info(f"Authorization successful for realm {realm_id}")
|
||||||
|
|
||||||
|
return render_template_string(
|
||||||
|
CALLBACK_TEMPLATE,
|
||||||
|
code=code,
|
||||||
|
realm_id=realm_id,
|
||||||
|
state=state,
|
||||||
|
error=None,
|
||||||
|
year=datetime.now().year
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/oauth/callback/json')
|
||||||
|
def oauth_callback_json():
|
||||||
|
"""
|
||||||
|
JSON endpoint for programmatic access.
|
||||||
|
Can be used if you want to implement automatic code relay.
|
||||||
|
"""
|
||||||
|
code = request.args.get('code')
|
||||||
|
realm_id = request.args.get('realmId')
|
||||||
|
state = request.args.get('state')
|
||||||
|
error = request.args.get('error')
|
||||||
|
error_description = request.args.get('error_description')
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': error,
|
||||||
|
'error_description': error_description
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'code': code,
|
||||||
|
'realm_id': realm_id,
|
||||||
|
'state': state
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
debug = os.environ.get('DEBUG', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
print(f"""
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ QBO Excel Sync - OAuth Callback Server ║
|
||||||
|
╠═══════════════════════════════════════════════════════════════╣
|
||||||
|
║ Server running on port {port} ║
|
||||||
|
║ Callback URL: http://localhost:{port}/oauth/callback ║
|
||||||
|
║ ║
|
||||||
|
║ For production, deploy with HTTPS (required by QuickBooks) ║
|
||||||
|
║ Example: https://yourcompany.com/oauth/callback ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
""")
|
||||||
|
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=debug)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# QBO Excel Sync Desktop App Requirements
|
||||||
|
# Install with: pip install -r requirements_desktop.txt
|
||||||
|
|
||||||
|
# Core dependencies (same as web app)
|
||||||
|
requests>=2.31.0
|
||||||
|
openpyxl>=3.1.2
|
||||||
|
|
||||||
|
# Windows-specific (for QuickBooks Desktop)
|
||||||
|
pywin32>=306; sys_platform == 'win32'
|
||||||
|
|
||||||
|
# Note: tkinter is included with Python on Windows
|
||||||
|
# On Linux, install with: sudo apt-get install python3-tk
|
||||||
@@ -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
|
||||||
+143
-4
@@ -29,6 +29,23 @@ class QBOCredentials:
|
|||||||
token_expiry: Optional[str] = None
|
token_expiry: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectionProfile:
|
||||||
|
"""Connection profile for QuickBooks Online."""
|
||||||
|
name: str
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
redirect_uri: str = "http://localhost:9000/oauth/callback"
|
||||||
|
environment: str = "sandbox" # 'sandbox' or 'production'
|
||||||
|
access_token: Optional[str] = None
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
realm_id: Optional[str] = None
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
token_expiry: Optional[str] = None
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FieldMapping:
|
class FieldMapping:
|
||||||
"""Single field mapping configuration."""
|
"""Single field mapping configuration."""
|
||||||
@@ -60,6 +77,8 @@ class ImportSettings:
|
|||||||
date_format: str = "%Y-%m-%d"
|
date_format: str = "%Y-%m-%d"
|
||||||
decimal_separator: str = "."
|
decimal_separator: str = "."
|
||||||
thousand_separator: str = ","
|
thousand_separator: str = ","
|
||||||
|
skip_empty_rows: bool = True
|
||||||
|
stop_on_error: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
@@ -87,8 +106,36 @@ class Settings:
|
|||||||
|
|
||||||
def _get_config_dir(self) -> Path:
|
def _get_config_dir(self) -> Path:
|
||||||
"""Get platform-specific config directory."""
|
"""Get platform-specific config directory."""
|
||||||
# For web app, use a local directory
|
import sys
|
||||||
base = Path(__file__).parent.parent.parent
|
|
||||||
|
# Use user's AppData folder for persistent storage
|
||||||
|
# This works correctly for both normal Python and PyInstaller executables
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
# Windows: Use AppData/Local
|
||||||
|
app_data = os.environ.get('LOCALAPPDATA')
|
||||||
|
if app_data:
|
||||||
|
return Path(app_data) / self.APP_NAME
|
||||||
|
# Fallback to APPDATA if LOCALAPPDATA not available
|
||||||
|
app_data = os.environ.get('APPDATA')
|
||||||
|
if app_data:
|
||||||
|
return Path(app_data) / self.APP_NAME
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
# macOS: Use ~/Library/Application Support
|
||||||
|
home = Path.home()
|
||||||
|
return home / "Library" / "Application Support" / self.APP_NAME
|
||||||
|
else:
|
||||||
|
# Linux/Unix: Use ~/.config
|
||||||
|
home = Path.home()
|
||||||
|
return home / ".config" / self.APP_NAME.lower()
|
||||||
|
|
||||||
|
# Final fallback: Use a 'data' folder relative to executable/script
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as PyInstaller executable
|
||||||
|
base = Path(sys.executable).parent
|
||||||
|
else:
|
||||||
|
# Running as script
|
||||||
|
base = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
return base / "data"
|
return base / "data"
|
||||||
|
|
||||||
def _load_config(self) -> Dict[str, Any]:
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
@@ -136,6 +183,21 @@ class Settings:
|
|||||||
self.save()
|
self.save()
|
||||||
logger.info("Import settings updated")
|
logger.info("Import settings updated")
|
||||||
|
|
||||||
|
def get_import_settings(self) -> ImportSettings:
|
||||||
|
"""Get import settings (method version for compatibility)."""
|
||||||
|
try:
|
||||||
|
settings_data = self._config.get("import_settings", {})
|
||||||
|
return ImportSettings(**settings_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error loading import settings: {e}")
|
||||||
|
return ImportSettings()
|
||||||
|
|
||||||
|
def save_import_settings(self, settings: ImportSettings):
|
||||||
|
"""Save import settings (method version for compatibility)."""
|
||||||
|
self._config["import_settings"] = asdict(settings)
|
||||||
|
self.save()
|
||||||
|
logger.info("Import settings saved")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recent_files(self) -> List[str]:
|
def recent_files(self) -> List[str]:
|
||||||
"""Get list of recently opened files."""
|
"""Get list of recently opened files."""
|
||||||
@@ -186,7 +248,11 @@ class Settings:
|
|||||||
try:
|
try:
|
||||||
with open(self.credentials_file, 'w') as f:
|
with open(self.credentials_file, 'w') as f:
|
||||||
json.dump(asdict(credentials), f)
|
json.dump(asdict(credentials), f)
|
||||||
os.chmod(self.credentials_file, 0o600) # Restrict permissions
|
# Restrict file permissions (Unix only, skip on Windows)
|
||||||
|
try:
|
||||||
|
os.chmod(self.credentials_file, 0o600)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass # chmod not supported on Windows
|
||||||
logger.info("Credentials saved successfully")
|
logger.info("Credentials saved successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save credentials: {e}")
|
logger.error(f"Failed to save credentials: {e}")
|
||||||
@@ -198,6 +264,79 @@ class Settings:
|
|||||||
self.credentials_file.unlink()
|
self.credentials_file.unlink()
|
||||||
logger.info("Credentials cleared")
|
logger.info("Credentials cleared")
|
||||||
|
|
||||||
|
# Connection Profile management
|
||||||
|
def get_profiles(self) -> List[ConnectionProfile]:
|
||||||
|
"""Get all connection profiles."""
|
||||||
|
profiles = []
|
||||||
|
profiles_file = self.config_dir / "profiles.json"
|
||||||
|
|
||||||
|
if profiles_file.exists():
|
||||||
|
try:
|
||||||
|
with open(profiles_file, 'r') as f:
|
||||||
|
profiles_data = json.load(f)
|
||||||
|
for p in profiles_data:
|
||||||
|
profiles.append(ConnectionProfile(**p))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load profiles: {e}")
|
||||||
|
|
||||||
|
return sorted(profiles, key=lambda p: p.name)
|
||||||
|
|
||||||
|
def get_profile(self, name: str) -> Optional[ConnectionProfile]:
|
||||||
|
"""Get a specific profile by name."""
|
||||||
|
profiles = self.get_profiles()
|
||||||
|
for p in profiles:
|
||||||
|
if p.name == name:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_profile(self, profile: ConnectionProfile):
|
||||||
|
"""Save a connection profile."""
|
||||||
|
profile.updated_at = datetime.now().isoformat()
|
||||||
|
profiles = self.get_profiles()
|
||||||
|
|
||||||
|
# Update existing or add new
|
||||||
|
found = False
|
||||||
|
for i, p in enumerate(profiles):
|
||||||
|
if p.name == profile.name:
|
||||||
|
profiles[i] = profile
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
profiles.append(profile)
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
profiles_file = self.config_dir / "profiles.json"
|
||||||
|
try:
|
||||||
|
with open(profiles_file, 'w') as f:
|
||||||
|
json.dump([asdict(p) for p in profiles], f, indent=2)
|
||||||
|
logger.info(f"Profile saved: {profile.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save profile: {e}")
|
||||||
|
|
||||||
|
def delete_profile(self, name: str):
|
||||||
|
"""Delete a connection profile."""
|
||||||
|
profiles = self.get_profiles()
|
||||||
|
profiles = [p for p in profiles if p.name != name]
|
||||||
|
|
||||||
|
profiles_file = self.config_dir / "profiles.json"
|
||||||
|
try:
|
||||||
|
with open(profiles_file, 'w') as f:
|
||||||
|
json.dump([asdict(p) for p in profiles], f, indent=2)
|
||||||
|
logger.info(f"Profile deleted: {name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete profile: {e}")
|
||||||
|
|
||||||
|
def get_active_profile_name(self) -> Optional[str]:
|
||||||
|
"""Get the name of the currently active profile."""
|
||||||
|
return self._config.get("active_profile", None)
|
||||||
|
|
||||||
|
def set_active_profile(self, name: Optional[str]):
|
||||||
|
"""Set the active profile name."""
|
||||||
|
self._config["active_profile"] = name
|
||||||
|
self.save()
|
||||||
|
logger.info(f"Active profile set to: {name}")
|
||||||
|
|
||||||
# Template management
|
# Template management
|
||||||
def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]:
|
def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]:
|
||||||
"""Get all mapping templates, optionally filtered by data type."""
|
"""Get all mapping templates, optionally filtered by data type."""
|
||||||
@@ -348,4 +487,4 @@ class Settings:
|
|||||||
FieldMapping("CurrentBalance", "CurrentBalance", transform="currency"),
|
FieldMapping("CurrentBalance", "CurrentBalance", transform="currency"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
+722
-267
File diff suppressed because it is too large
Load Diff
@@ -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