Update desktop app

This commit is contained in:
2026-02-18 10:23:36 -05:00
parent 34d73a6d2c
commit 0f913ae3c9
+474 -52
View File
@@ -335,10 +335,10 @@ class QBOExcelSyncApp:
self.notebook = ttk.Notebook(self.main_frame) self.notebook = ttk.Notebook(self.main_frame)
self.notebook.pack(fill=tk.BOTH, expand=True) self.notebook.pack(fill=tk.BOTH, expand=True)
# Create tabs # Create tabs - Order: Dashboard | Connection | Import | Templates | Lookup
self._create_dashboard_tab() self._create_dashboard_tab()
self._create_import_tab()
self._create_connection_tab() self._create_connection_tab()
self._create_import_tab()
self._create_templates_tab() self._create_templates_tab()
self._create_lookup_tab() self._create_lookup_tab()
@@ -377,15 +377,6 @@ class QBOExcelSyncApp:
) )
self.conn_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) self.conn_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
# Environment card
env_card = self._create_stat_card(
stats_frame,
"Environment",
self.settings.qbo_environment.title(),
"Test mode" if self.settings.qbo_environment == 'sandbox' else "Live data"
)
env_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=10)
# Supported types card # Supported types card
types_card = self._create_stat_card( types_card = self._create_stat_card(
stats_frame, stats_frame,
@@ -404,22 +395,29 @@ class QBOExcelSyncApp:
ttk.Button( ttk.Button(
actions_inner, actions_inner,
text="📁 Import Excel File", text="🔗 Manage Connection",
command=lambda: self.notebook.select(1), # Switch to Import tab command=lambda: self.notebook.select(1), # Connection tab (index 1)
width=25 width=25
).pack(side=tk.LEFT, padx=5) ).pack(side=tk.LEFT, padx=5)
ttk.Button( ttk.Button(
actions_inner, actions_inner,
text="🔗 Manage Connection", text="📁 Import Excel File",
command=lambda: self.notebook.select(2), # Switch to Connection tab command=lambda: self.notebook.select(2), # Import tab (index 2)
width=25 width=25
).pack(side=tk.LEFT, padx=5) ).pack(side=tk.LEFT, padx=5)
ttk.Button( ttk.Button(
actions_inner, actions_inner,
text="📋 Manage Templates", text="📋 Manage Templates",
command=lambda: self.notebook.select(3), # Switch to Templates tab command=lambda: self.notebook.select(3), # Templates tab (index 3)
width=25
).pack(side=tk.LEFT, padx=5)
ttk.Button(
actions_inner,
text="🔍 QuickBooks Lookup",
command=lambda: self.notebook.select(4), # Lookup tab (index 4)
width=25 width=25
).pack(side=tk.LEFT, padx=5) ).pack(side=tk.LEFT, padx=5)
@@ -666,6 +664,15 @@ class QBOExcelSyncApp:
ttk.Button(profile_frame, text="Save As...", command=self._save_profile_as, width=10).pack(side=tk.LEFT, padx=2) ttk.Button(profile_frame, text="Save As...", command=self._save_profile_as, width=10).pack(side=tk.LEFT, padx=2)
ttk.Button(profile_frame, text="Delete", command=self._delete_profile, width=8).pack(side=tk.LEFT, padx=2) ttk.Button(profile_frame, text="Delete", command=self._delete_profile, width=8).pack(side=tk.LEFT, padx=2)
# Import/Export buttons row
profile_io_frame = ttk.Frame(self.qbo_frame)
profile_io_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Label(profile_io_frame, text="Profile Import/Export:", foreground='gray').pack(side=tk.LEFT)
ttk.Button(profile_io_frame, text="Import Profile", command=self._import_profile, width=14).pack(side=tk.LEFT, padx=(10, 2))
ttk.Button(profile_io_frame, text="Export Profile", command=self._export_profile, width=14).pack(side=tk.LEFT, padx=2)
ttk.Button(profile_io_frame, text="Export All", command=self._export_all_profiles, width=12).pack(side=tk.LEFT, padx=2)
# Separator # Separator
ttk.Separator(self.qbo_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) ttk.Separator(self.qbo_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
@@ -1837,8 +1844,11 @@ except Exception as e:
sys.exit(1) sys.exit(1)
''' '''
# Get Python executable (handles PyInstaller bundled case)
python_exe = self._get_python_executable()
result = subprocess.run( result = subprocess.run(
[sys.executable, '-c', connect_script], [python_exe, '-c', connect_script],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 timeout=60
@@ -2146,6 +2156,195 @@ except Exception as e:
messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.") messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.")
logger.info(f"[PROFILE] Deleted profile: {profile_name}") logger.info(f"[PROFILE] Deleted profile: {profile_name}")
def _import_profile(self):
"""Import profile(s) from a JSON file."""
file_path = filedialog.askopenfilename(
title="Import Profile",
filetypes=[
("JSON Files", "*.json"),
("All Files", "*.*")
]
)
if not file_path:
return
try:
with open(file_path, 'r') as f:
data = json.load(f)
# Handle both single profile and multiple profiles
if isinstance(data, list):
profiles_data = data
elif isinstance(data, dict):
profiles_data = [data]
else:
raise ValueError("Invalid profile format")
imported_count = 0
skipped_count = 0
for profile_data in profiles_data:
# Validate required fields
if 'name' not in profile_data:
skipped_count += 1
continue
profile_name = profile_data['name']
# Check if profile already exists
existing = self.settings.get_profile(profile_name)
if existing:
overwrite = messagebox.askyesno(
"Profile Exists",
f"Profile '{profile_name}' already exists. Overwrite?"
)
if not overwrite:
skipped_count += 1
continue
# Create ConnectionProfile from data
profile = ConnectionProfile(
name=profile_data.get('name', ''),
client_id=profile_data.get('client_id', ''),
client_secret=profile_data.get('client_secret', ''),
redirect_uri=profile_data.get('redirect_uri', 'http://localhost:9000/oauth/callback'),
environment=profile_data.get('environment', 'sandbox'),
access_token=profile_data.get('access_token'),
refresh_token=profile_data.get('refresh_token'),
realm_id=profile_data.get('realm_id'),
company_name=profile_data.get('company_name'),
token_expiry=profile_data.get('token_expiry'),
created_at=profile_data.get('created_at', datetime.now().isoformat()),
updated_at=datetime.now().isoformat(),
)
self.settings.save_profile(profile)
imported_count += 1
logger.info(f"[PROFILE] Imported profile: {profile_name}")
# Refresh the dropdown
self._refresh_profiles()
# Show result
msg = f"Imported {imported_count} profile(s)."
if skipped_count > 0:
msg += f"\nSkipped {skipped_count} profile(s)."
messagebox.showinfo("Import Complete", msg)
except json.JSONDecodeError as e:
messagebox.showerror("Import Error", f"Invalid JSON file: {str(e)}")
logger.error(f"[PROFILE] Import failed - invalid JSON: {e}")
except Exception as e:
messagebox.showerror("Import Error", f"Failed to import profile: {str(e)}")
logger.error(f"[PROFILE] Import failed: {e}")
def _export_profile(self):
"""Export the selected profile to a JSON file."""
profile_name = self.profile_var.get()
if not profile_name:
messagebox.showwarning("No Profile", "Please select a profile to export.")
return
profile = self.settings.get_profile(profile_name)
if not profile:
messagebox.showerror("Error", f"Profile '{profile_name}' not found.")
return
# Ask whether to include tokens (sensitive data)
include_tokens = messagebox.askyesno(
"Include Tokens?",
"Do you want to include access tokens in the export?\n\n"
"• Yes - Include tokens (allows immediate connection on import)\n"
"• No - Exclude tokens (more secure, requires re-authentication)"
)
# Prepare export data
from dataclasses import asdict
export_data = asdict(profile)
if not include_tokens:
export_data['access_token'] = None
export_data['refresh_token'] = None
export_data['token_expiry'] = None
# Get save location
safe_name = "".join(c for c in profile_name if c.isalnum() or c in " -_").strip()
file_path = filedialog.asksaveasfilename(
title="Export Profile",
defaultextension=".json",
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
initialfile=f"qbo_profile_{safe_name}.json"
)
if not file_path:
return
try:
with open(file_path, 'w') as f:
json.dump(export_data, f, indent=2)
messagebox.showinfo("Export Complete", f"Profile '{profile_name}' exported successfully.")
logger.info(f"[PROFILE] Exported profile: {profile_name} to {file_path}")
except Exception as e:
messagebox.showerror("Export Error", f"Failed to export profile: {str(e)}")
logger.error(f"[PROFILE] Export failed: {e}")
def _export_all_profiles(self):
"""Export all profiles to a single JSON file."""
profiles = self.settings.get_profiles()
if not profiles:
messagebox.showwarning("No Profiles", "No profiles to export.")
return
# Ask whether to include tokens (sensitive data)
include_tokens = messagebox.askyesno(
"Include Tokens?",
f"Exporting {len(profiles)} profile(s).\n\n"
"Do you want to include access tokens in the export?\n\n"
"• Yes - Include tokens (allows immediate connection on import)\n"
"• No - Exclude tokens (more secure, requires re-authentication)"
)
# Prepare export data
from dataclasses import asdict
export_data = []
for profile in profiles:
profile_dict = asdict(profile)
if not include_tokens:
profile_dict['access_token'] = None
profile_dict['refresh_token'] = None
profile_dict['token_expiry'] = None
export_data.append(profile_dict)
# Get save location
file_path = filedialog.asksaveasfilename(
title="Export All Profiles",
defaultextension=".json",
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
initialfile=f"qbo_profiles_backup_{datetime.now().strftime('%Y%m%d')}.json"
)
if not file_path:
return
try:
with open(file_path, 'w') as f:
json.dump(export_data, f, indent=2)
messagebox.showinfo(
"Export Complete",
f"Exported {len(profiles)} profile(s) successfully."
)
logger.info(f"[PROFILE] Exported {len(profiles)} profiles to {file_path}")
except Exception as e:
messagebox.showerror("Export Error", f"Failed to export profiles: {str(e)}")
logger.error(f"[PROFILE] Export all failed: {e}")
# ========================================================================= # =========================================================================
# Import Methods # Import Methods
# ========================================================================= # =========================================================================
@@ -3649,6 +3848,46 @@ except Exception as e:
return [] return []
def _get_python_executable(self) -> str:
"""Get the path to a Python executable for subprocess calls.
When running as a PyInstaller bundle, sys.executable points to the bundle,
so we need to find the system Python installation."""
import shutil
# Check if we're running as a PyInstaller bundle
if getattr(sys, 'frozen', False):
# Try to find system Python
# Check common Python installation paths on Windows
python_paths = [
shutil.which('python'),
shutil.which('python3'),
shutil.which('py'),
r'C:\Python312\python.exe',
r'C:\Python311\python.exe',
r'C:\Python310\python.exe',
r'C:\Python39\python.exe',
r'C:\Python38\python.exe',
os.path.expandvars(r'%LOCALAPPDATA%\Programs\Python\Python312\python.exe'),
os.path.expandvars(r'%LOCALAPPDATA%\Programs\Python\Python311\python.exe'),
os.path.expandvars(r'%LOCALAPPDATA%\Programs\Python\Python310\python.exe'),
os.path.expandvars(r'%LOCALAPPDATA%\Programs\Python\Python39\python.exe'),
]
for python_path in python_paths:
if python_path and os.path.isfile(python_path):
logger.info(f"Found system Python at: {python_path}")
return python_path
# If no system Python found, raise an error
raise Exception(
"QuickBooks Desktop requires Python to be installed on your system.\n"
"Please install Python from https://www.python.org/downloads/\n"
"and make sure to check 'Add Python to PATH' during installation."
)
else:
# Running as script, use current Python
return sys.executable
def _fetch_qbd_entities(self, entity_type: str, search_term: str = None, type_filter: str = None) -> List[Dict]: def _fetch_qbd_entities(self, entity_type: str, search_term: str = None, type_filter: str = None) -> List[Dict]:
"""Fetch entities from QuickBooks Desktop using subprocess for COM compatibility.""" """Fetch entities from QuickBooks Desktop using subprocess for COM compatibility."""
if not HAS_QBD: if not HAS_QBD:
@@ -3767,8 +4006,11 @@ except Exception as e:
''' '''
try: try:
# Get Python executable (handles PyInstaller bundled case)
python_exe = self._get_python_executable()
result = subprocess.run( result = subprocess.run(
[sys.executable, '-c', query_script], [python_exe, '-c', query_script],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120 timeout=120
@@ -3969,74 +4211,254 @@ except Exception as e:
dialog.title("Application Logs") dialog.title("Application Logs")
dialog.geometry("800x500") dialog.geometry("800x500")
# Read log file # Read log file - check multiple possible locations
log_file = PROJECT_ROOT / 'desktop_app.log' possible_log_paths = [
Path('desktop_app.log'), # Current working directory
PROJECT_ROOT / 'desktop_app.log', # Script directory
Path(sys.executable).parent / 'desktop_app.log', # Executable directory
Path.home() / 'desktop_app.log', # Home directory
]
log_file = None
for path in possible_log_paths:
if path.exists():
log_file = path
break
text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=('Consolas', 9)) text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=('Consolas', 9))
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
if log_file.exists(): if log_file and log_file.exists():
with open(log_file, 'r') as f: try:
text.insert(tk.END, f.read()) with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
text.insert(tk.END, f.read())
text.insert(tk.END, f"\n\n--- Log file: {log_file} ---")
except Exception as e:
text.insert(tk.END, f"Error reading log file: {e}")
else: else:
text.insert(tk.END, "No log file found.") text.insert(tk.END, f"No log file found.\n\nSearched locations:\n")
for path in possible_log_paths:
text.insert(tk.END, f" - {path}\n")
text.config(state=tk.DISABLED) text.config(state=tk.DISABLED)
# Scroll to end
text.see(tk.END)
btn_frame = ttk.Frame(dialog) btn_frame = ttk.Frame(dialog)
btn_frame.pack(fill=tk.X, padx=10, pady=5) btn_frame.pack(fill=tk.X, padx=10, pady=5)
def clear_logs(): def clear_logs():
if messagebox.askyesno("Confirm", "Clear all logs?"): if messagebox.askyesno("Confirm", "Clear all logs?"):
if log_file.exists(): if log_file and log_file.exists():
log_file.unlink() log_file.unlink()
text.config(state=tk.NORMAL) text.config(state=tk.NORMAL)
text.delete(1.0, tk.END) text.delete(1.0, tk.END)
text.insert(tk.END, "Logs cleared.") text.insert(tk.END, "Logs cleared.")
text.config(state=tk.DISABLED) text.config(state=tk.DISABLED)
def open_log_folder():
if log_file:
folder = log_file.parent
else:
folder = Path.cwd()
if sys.platform == 'win32':
os.startfile(folder)
elif sys.platform == 'darwin':
subprocess.run(['open', folder])
else:
subprocess.run(['xdg-open', folder])
ttk.Button(btn_frame, text="Clear Logs", command=clear_logs).pack(side=tk.LEFT) ttk.Button(btn_frame, text="Clear Logs", command=clear_logs).pack(side=tk.LEFT)
ttk.Button(btn_frame, text="Refresh", command=lambda: dialog.destroy() or self._show_logs()).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Open Folder", command=open_log_folder).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Refresh", command=lambda: (dialog.destroy(), self._show_logs())).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Close", command=dialog.destroy).pack(side=tk.RIGHT) ttk.Button(btn_frame, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
def _show_help(self): def _show_help(self):
"""Show help documentation.""" """Show help documentation."""
help_text = """ help_text = """
QBO Excel Sync - Desktop Application ╔═══════════════════════════════════════════════════════════════════════════╗
===================================== ║ QBO EXCEL SYNC - USER GUIDE ║
╚═══════════════════════════════════════════════════════════════════════════╝
This application allows you to import Excel data into QuickBooks Online TABLE OF CONTENTS
or QuickBooks Desktop. ─────────────────
1. Installation & Setup
2. Connecting to QuickBooks
3. Connection Profiles
4. Importing Data
5. Field Mapping & Templates
6. QuickBooks Lookup
7. Troubleshooting
Quick Start: ═══════════════════════════════════════════════════════════════════════════
1. Connect to QuickBooks (Connection tab) 1. INSTALLATION & SETUP
2. Select an Excel file (Import tab) ═══════════════════════════════════════════════════════════════════════════
3. Choose the data type (Check, Invoice, Bill, etc.)
4. Map Excel columns to QuickBooks fields
5. Click "Start Import"
Supported Data Types: FOR EXECUTABLE VERSION (.exe):
- Checks: Payment checks with payee and amount • Simply double-click QBO_Excel_Sync.exe to run
- Invoices: Customer invoices with line items • No installation required
- Bills: Vendor bills with expense accounts • For QuickBooks Desktop: Python must be installed on your system
- Customers: New customer records
- Vendors: New vendor records
- Accounts: Chart of accounts entries
Tips: FOR SOURCE VERSION:
- Use "Auto-Map" to automatically match common field names 1. Install Python 3.8 or higher from https://www.python.org
- Save frequently used mappings as templates 2. Install dependencies: pip install -r requirements.txt
- Preview your data before importing 3. Run: python desktop_app.py
- Check the import log for any errors
For more information, visit the documentation website. BUILDING THE EXECUTABLE:
1. Run build_windows.bat (Windows)
2. Find executable in dist/QBO_Excel_Sync.exe
═══════════════════════════════════════════════════════════════════════════
2. CONNECTING TO QUICKBOOKS
═══════════════════════════════════════════════════════════════════════════
QUICKBOOKS ONLINE:
1. Go to the Connection tab
2. Select "QuickBooks Online"
3. Enter your API credentials:
• Client ID: From Intuit Developer Portal
• Client Secret: From Intuit Developer Portal
• Redirect URI: Your callback URL
• Environment: sandbox (testing) or production (live)
4. Click "Connect to QuickBooks"
5. Authorize in the browser
6. For remote callback servers: Copy the code back to the app
QUICKBOOKS DESKTOP:
1. Open QuickBooks Desktop with your company file
2. Go to the Connection tab
3. Select "QuickBooks Desktop"
4. Click "Connect to QuickBooks Desktop"
5. Authorize the connection in QuickBooks Desktop
═══════════════════════════════════════════════════════════════════════════
3. CONNECTION PROFILES
═══════════════════════════════════════════════════════════════════════════
Profiles save your connection settings for easy switching between companies.
MANAGING PROFILES:
• Load: Load the selected profile into the form
• Save: Save current settings to the selected profile
• Save As: Create a new profile with current settings
• Delete: Remove the selected profile
IMPORT/EXPORT PROFILES:
• Import Profile: Load profile from a JSON file
• Export Profile: Save selected profile to a JSON file
• Export All: Backup all profiles to a single JSON file
When exporting, you can choose to include or exclude access tokens:
• Include tokens: Can connect immediately after import
• Exclude tokens: More secure, requires re-authentication
═══════════════════════════════════════════════════════════════════════════
4. IMPORTING DATA
═══════════════════════════════════════════════════════════════════════════
SUPPORTED DATA TYPES:
• Checks: Payment checks with payee, bank account, and expense details
• Invoices: Customer invoices with line items
• Bills: Vendor bills with expense accounts
• Customers: Customer records
• Vendors: Vendor records
• Accounts: Chart of accounts entries
IMPORT PROCESS:
1. Click "Browse" to select an Excel file (.xlsx, .xls)
2. Select the data type from the dropdown
3. Map Excel columns to QuickBooks fields (or use Auto-Map)
4. Click "Preview" to verify data
5. Click "Start Import" to begin
IMPORT OPTIONS (Tools → Settings):
• Skip Empty Rows: Ignore rows with no data
• Validate Before Import: Check data before sending
• Stop on Error: Halt import if an error occurs
• Date Format: Expected date format in your Excel file
═══════════════════════════════════════════════════════════════════════════
5. FIELD MAPPING & TEMPLATES
═══════════════════════════════════════════════════════════════════════════
FIELD MAPPING:
• Select an Excel column on the left
• Select a QuickBooks field on the right
• Click "Add Mapping" to create the link
• Use "Auto-Map" to automatically match common field names
TEMPLATES:
• Save frequently used mappings as templates
• Load templates to quickly apply mappings
• Templates are stored locally and persist between sessions
REQUIRED FIELDS (vary by data type):
• Checks: Payee, Bank Account, Date, Amount, Expense Account
• Invoices: Customer, Date, Line Items
• Bills: Vendor, Date, Expense Account, Amount
═══════════════════════════════════════════════════════════════════════════
6. QUICKBOOKS LOOKUP
═══════════════════════════════════════════════════════════════════════════
The Lookup tab lets you search and view QuickBooks data.
SEARCHABLE ENTITIES:
• Accounts (filter by type: Bank, Expense, Income, etc.)
• Customers
• Vendors
• Items
• Employees
• Classes
• Payment Methods
• Terms
FEATURES:
• Search by name
• Filter accounts by type
• Export results to CSV
• Double-click to copy ID and name
• View both Raw ID and Clean ID (for QuickBooks Desktop)
═══════════════════════════════════════════════════════════════════════════
7. TROUBLESHOOTING
═══════════════════════════════════════════════════════════════════════════
CONNECTION ISSUES:
"Invalid account type" error: Verify the Bank Account is type "Bank"
• OAuth errors: Check Client ID, Secret, and Redirect URI match exactly
• Production sandbox mismatch: Ensure Environment setting is correct
QUICKBOOKS DESKTOP:
"No module named encodings": Install Python on your system
• Connection timeout: Ensure QuickBooks Desktop is open with company file
• Authorization denied: Authorize the app in QuickBooks Desktop
IMPORT ERRORS:
"Entity not found": Check that referenced accounts/customers exist
"Invalid date": Verify date format matches your settings
• Account type errors: Use correct account types (Bank for checks, etc.)
LOG FILES:
• View logs: Tools → View Logs
• Log location: Same directory as the application
GETTING HELP:
• Check the log file for detailed error messages
• Verify your data in the Preview before importing
• Use the Lookup tab to find correct account names and IDs
═══════════════════════════════════════════════════════════════════════════
© 2024-2026 QBO Excel Sync
═══════════════════════════════════════════════════════════════════════════
""" """
dialog = tk.Toplevel(self.root) dialog = tk.Toplevel(self.root)
dialog.title("Help") dialog.title("Help - User Guide")
dialog.geometry("600x500") dialog.geometry("750x600")
text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=('Segoe UI', 10)) text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=('Consolas', 10))
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
text.insert(tk.END, help_text) text.insert(tk.END, help_text)
text.config(state=tk.DISABLED) text.config(state=tk.DISABLED)