diff --git a/desktop_app.py b/desktop_app.py index d133004..d4561c9 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -335,10 +335,10 @@ class QBOExcelSyncApp: self.notebook = ttk.Notebook(self.main_frame) self.notebook.pack(fill=tk.BOTH, expand=True) - # Create tabs + # Create tabs - Order: Dashboard | Connection | Import | Templates | Lookup self._create_dashboard_tab() - self._create_import_tab() self._create_connection_tab() + self._create_import_tab() self._create_templates_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)) - # 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 types_card = self._create_stat_card( stats_frame, @@ -404,22 +395,29 @@ class QBOExcelSyncApp: ttk.Button( actions_inner, - text="📁 Import Excel File", - command=lambda: self.notebook.select(1), # Switch to Import tab + text="🔗 Manage Connection", + command=lambda: self.notebook.select(1), # Connection tab (index 1) width=25 ).pack(side=tk.LEFT, padx=5) ttk.Button( actions_inner, - text="🔗 Manage Connection", - command=lambda: self.notebook.select(2), # Switch to Connection tab + text="📁 Import Excel File", + command=lambda: self.notebook.select(2), # Import tab (index 2) width=25 ).pack(side=tk.LEFT, padx=5) ttk.Button( actions_inner, 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 ).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="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 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) ''' + # Get Python executable (handles PyInstaller bundled case) + python_exe = self._get_python_executable() + result = subprocess.run( - [sys.executable, '-c', connect_script], + [python_exe, '-c', connect_script], capture_output=True, text=True, timeout=60 @@ -2146,6 +2156,195 @@ except Exception as e: messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.") 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 # ========================================================================= @@ -3649,6 +3848,46 @@ except Exception as e: 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]: """Fetch entities from QuickBooks Desktop using subprocess for COM compatibility.""" if not HAS_QBD: @@ -3767,8 +4006,11 @@ except Exception as e: ''' try: + # Get Python executable (handles PyInstaller bundled case) + python_exe = self._get_python_executable() + result = subprocess.run( - [sys.executable, '-c', query_script], + [python_exe, '-c', query_script], capture_output=True, text=True, timeout=120 @@ -3969,74 +4211,254 @@ except Exception as e: dialog.title("Application Logs") dialog.geometry("800x500") - # Read log file - log_file = PROJECT_ROOT / 'desktop_app.log' + # Read log file - check multiple possible locations + 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.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - if log_file.exists(): - with open(log_file, 'r') as f: - text.insert(tk.END, f.read()) + if log_file and log_file.exists(): + try: + 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: - 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) + # Scroll to end + text.see(tk.END) + btn_frame = ttk.Frame(dialog) btn_frame.pack(fill=tk.X, padx=10, pady=5) def clear_logs(): if messagebox.askyesno("Confirm", "Clear all logs?"): - if log_file.exists(): + if log_file and log_file.exists(): log_file.unlink() text.config(state=tk.NORMAL) text.delete(1.0, tk.END) text.insert(tk.END, "Logs cleared.") 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="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) def _show_help(self): """Show help documentation.""" help_text = """ -QBO Excel Sync - Desktop Application -===================================== +╔═══════════════════════════════════════════════════════════════════════════╗ +║ QBO EXCEL SYNC - USER GUIDE ║ +╚═══════════════════════════════════════════════════════════════════════════╝ -This application allows you to import Excel data into QuickBooks Online -or QuickBooks Desktop. +TABLE OF CONTENTS +───────────────── +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) -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" +═══════════════════════════════════════════════════════════════════════════ +1. INSTALLATION & SETUP +═══════════════════════════════════════════════════════════════════════════ -Supported Data Types: -- Checks: Payment checks with payee and amount -- Invoices: Customer invoices with line items -- Bills: Vendor bills with expense accounts -- Customers: New customer records -- Vendors: New vendor records -- Accounts: Chart of accounts entries +FOR EXECUTABLE VERSION (.exe): +• Simply double-click QBO_Excel_Sync.exe to run +• No installation required +• For QuickBooks Desktop: Python must be installed on your system -Tips: -- Use "Auto-Map" to automatically match common field names -- Save frequently used mappings as templates -- Preview your data before importing -- Check the import log for any errors +FOR SOURCE VERSION: +1. Install Python 3.8 or higher from https://www.python.org +2. Install dependencies: pip install -r requirements.txt +3. Run: python desktop_app.py -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.title("Help") - dialog.geometry("600x500") + dialog.title("Help - User Guide") + 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.insert(tk.END, help_text) text.config(state=tk.DISABLED)