diff --git a/desktop_app.py b/desktop_app.py index 2a890de..2924289 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -336,6 +336,16 @@ class QBOExcelSyncApp: lookup_menu.add_command(label="Payment Methods...", command=lambda: self._show_qb_lookup('paymentmethod')) lookup_menu.add_command(label="Employees...", command=lambda: self._show_qb_lookup('employee')) + tools_menu.add_separator() + + # QBO Data Backup/Restore submenu + backup_menu = tk.Menu(tools_menu, tearoff=0) + tools_menu.add_cascade(label="QBO Data Backup/Restore", menu=backup_menu) + backup_menu.add_command(label="Backup Company Data...", command=self._backup_qbo_data) + backup_menu.add_command(label="Restore Company Data...", command=self._restore_qbo_data) + backup_menu.add_separator() + backup_menu.add_command(label="Export to Excel...", command=self._export_qbo_to_excel) + tools_menu.add_separator() tools_menu.add_command(label="View Logs...", command=self._show_logs) @@ -4494,6 +4504,611 @@ except Exception as e: ttk.Button(dialog, text="Save", command=save_settings).pack(pady=10) + # ========================================================================= + # QBO Data Backup/Restore Methods + # ========================================================================= + + def _backup_qbo_data(self): + """Backup QuickBooks Online company data to a JSON file.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + if self.connection_type != 'qbo': + messagebox.showwarning("QBO Only", "Data backup is currently only supported for QuickBooks Online.") + return + + # Show backup options dialog + dialog = tk.Toplevel(self.root) + dialog.title("Backup QBO Company Data") + dialog.geometry("500x450") + dialog.transient(self.root) + dialog.grab_set() + + ttk.Label(dialog, text="Select data types to backup:", font=('Segoe UI', 11, 'bold')).pack(pady=(15, 10)) + + # Data type checkboxes + data_types = [ + ('accounts', 'Chart of Accounts', True), + ('customers', 'Customers', True), + ('vendors', 'Vendors', True), + ('items', 'Items/Products & Services', True), + ('employees', 'Employees', False), + ('classes', 'Classes', False), + ('departments', 'Departments/Locations', False), + ('terms', 'Payment Terms', False), + ('paymentmethods', 'Payment Methods', False), + ] + + backup_vars = {} + check_frame = ttk.Frame(dialog) + check_frame.pack(fill=tk.X, padx=30, pady=10) + + for i, (key, label, default) in enumerate(data_types): + var = tk.BooleanVar(value=default) + backup_vars[key] = var + cb = ttk.Checkbutton(check_frame, text=label, variable=var) + cb.grid(row=i // 2, column=i % 2, sticky='w', padx=10, pady=3) + + # Select all / none buttons + btn_frame = ttk.Frame(dialog) + btn_frame.pack(fill=tk.X, padx=30) + + def select_all(): + for var in backup_vars.values(): + var.set(True) + + def select_none(): + for var in backup_vars.values(): + var.set(False) + + ttk.Button(btn_frame, text="Select All", command=select_all, width=12).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Select None", command=select_none, width=12).pack(side=tk.LEFT, padx=5) + + # Progress + progress_frame = ttk.Frame(dialog) + progress_frame.pack(fill=tk.X, padx=30, pady=20) + + progress_label = ttk.Label(progress_frame, text="Ready to backup") + progress_label.pack(anchor=tk.W) + + progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=400) + progress_bar.pack(fill=tk.X, pady=5) + + status_label = ttk.Label(progress_frame, text="", foreground='gray') + status_label.pack(anchor=tk.W) + + def start_backup(): + selected = [k for k, v in backup_vars.items() if v.get()] + if not selected: + messagebox.showwarning("No Selection", "Please select at least one data type to backup.") + return + + # Get save location + safe_company = "".join(c for c in self.company_name if c.isalnum() or c in " -_").strip() + file_path = filedialog.asksaveasfilename( + title="Save Backup File", + defaultextension=".json", + filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")], + initialfile=f"qbo_backup_{safe_company}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + ) + + if not file_path: + return + + # Disable buttons during backup + backup_btn.config(state=tk.DISABLED) + + def backup_thread(): + try: + backup_data = { + 'backup_info': { + 'version': '1.0', + 'created_at': datetime.now().isoformat(), + 'company_name': self.company_name, + 'realm_id': getattr(self.qbo_client, 'realm_id', ''), + 'data_types': selected + }, + 'data': {} + } + + total_steps = len(selected) + current_step = 0 + + entity_map = { + 'accounts': ('Account', 'accounts'), + 'customers': ('Customer', 'customers'), + 'vendors': ('Vendor', 'vendors'), + 'items': ('Item', 'items'), + 'employees': ('Employee', 'employees'), + 'classes': ('Class', 'classes'), + 'departments': ('Department', 'departments'), + 'terms': ('Term', 'terms'), + 'paymentmethods': ('PaymentMethod', 'paymentmethods'), + } + + for data_type in selected: + current_step += 1 + progress = int((current_step / total_steps) * 100) + + query_name, key = entity_map.get(data_type, (data_type.title(), data_type)) + + dialog.after(0, lambda p=progress, t=data_type, cs=current_step, ts=total_steps: ( + progress_bar.configure(value=p), + progress_label.configure(text=f"Backing up {t}..."), + status_label.configure(text=f"Step {cs} of {ts}") + )) + + try: + # Query all entities of this type using _make_request + query = f"SELECT * FROM {query_name} MAXRESULTS 1000" + response = self.qbo_client._make_request('GET', 'query', params={'query': query}) + + if response and 'QueryResponse' in response: + entities = response['QueryResponse'].get(query_name, []) + else: + entities = [] + + backup_data['data'][key] = entities + logger.info(f"[BACKUP] Backed up {len(entities)} {key}") + except Exception as e: + logger.warning(f"[BACKUP] Failed to backup {key}: {e}") + backup_data['data'][key] = [] + + # Save to file + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(backup_data, f, indent=2, default=str) + + # Calculate totals + total_records = sum(len(v) for v in backup_data['data'].values()) + + dialog.after(0, lambda: ( + progress_bar.configure(value=100), + progress_label.configure(text="Backup completed!"), + status_label.configure(text=f"Total: {total_records} records saved"), + backup_btn.configure(state=tk.NORMAL), + messagebox.showinfo("Backup Complete", + f"Successfully backed up {total_records} records from {self.company_name}.\n\n" + f"File saved to:\n{file_path}") + )) + + logger.info(f"[BACKUP] Completed backup to {file_path} - {total_records} total records") + + except Exception as e: + dialog.after(0, lambda: ( + progress_label.configure(text="Backup failed!"), + status_label.configure(text=str(e)), + backup_btn.configure(state=tk.NORMAL), + messagebox.showerror("Backup Failed", f"Error during backup: {str(e)}") + )) + logger.error(f"[BACKUP] Failed: {e}") + + threading.Thread(target=backup_thread, daemon=True).start() + + # Buttons + action_frame = ttk.Frame(dialog) + action_frame.pack(fill=tk.X, padx=30, pady=15) + + backup_btn = ttk.Button(action_frame, text="Start Backup", command=start_backup, width=15) + backup_btn.pack(side=tk.LEFT, padx=5) + + ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5) + + def _restore_qbo_data(self): + """Restore QuickBooks Online company data from a backup file.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + if self.connection_type != 'qbo': + messagebox.showwarning("QBO Only", "Data restore is currently only supported for QuickBooks Online.") + return + + # Select backup file + file_path = filedialog.askopenfilename( + title="Select Backup File", + filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")] + ) + + if not file_path: + return + + try: + with open(file_path, 'r', encoding='utf-8') as f: + backup_data = json.load(f) + except Exception as e: + messagebox.showerror("Error", f"Failed to read backup file: {e}") + return + + # Validate backup file + if 'backup_info' not in backup_data or 'data' not in backup_data: + messagebox.showerror("Invalid File", "This doesn't appear to be a valid QBO backup file.") + return + + backup_info = backup_data['backup_info'] + + # Show restore dialog + dialog = tk.Toplevel(self.root) + dialog.title("Restore QBO Company Data") + dialog.geometry("550x500") + dialog.transient(self.root) + dialog.grab_set() + + # Backup info + info_frame = ttk.LabelFrame(dialog, text="Backup Information", padding=10) + info_frame.pack(fill=tk.X, padx=15, pady=10) + + ttk.Label(info_frame, text=f"Company: {backup_info.get('company_name', 'Unknown')}").pack(anchor=tk.W) + ttk.Label(info_frame, text=f"Created: {backup_info.get('created_at', 'Unknown')[:19]}").pack(anchor=tk.W) + + # Warning + warning_frame = ttk.Frame(dialog) + warning_frame.pack(fill=tk.X, padx=15, pady=5) + ttk.Label(warning_frame, text="⚠️ Warning: Restoring will create new records. Existing records won't be modified.", + foreground='orange', wraplength=500).pack(anchor=tk.W) + + # Data to restore + ttk.Label(dialog, text="Select data types to restore:", font=('Segoe UI', 10, 'bold')).pack(padx=15, pady=(10, 5), anchor=tk.W) + + restore_vars = {} + data_frame = ttk.Frame(dialog) + data_frame.pack(fill=tk.X, padx=30, pady=5) + + row = 0 + for key, records in backup_data['data'].items(): + count = len(records) if records else 0 + var = tk.BooleanVar(value=count > 0) + restore_vars[key] = var + + cb = ttk.Checkbutton(data_frame, text=f"{key.title()} ({count} records)", variable=var, + state=tk.NORMAL if count > 0 else tk.DISABLED) + cb.grid(row=row // 2, column=row % 2, sticky='w', padx=10, pady=2) + row += 1 + + # Progress + progress_frame = ttk.Frame(dialog) + progress_frame.pack(fill=tk.X, padx=15, pady=15) + + progress_label = ttk.Label(progress_frame, text="Ready to restore") + progress_label.pack(anchor=tk.W) + + progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=480) + progress_bar.pack(fill=tk.X, pady=5) + + status_label = ttk.Label(progress_frame, text="", foreground='gray') + status_label.pack(anchor=tk.W) + + # Results text + results_frame = ttk.LabelFrame(dialog, text="Results", padding=5) + results_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=5) + + results_text = scrolledtext.ScrolledText(results_frame, height=6, font=('Consolas', 9)) + results_text.pack(fill=tk.BOTH, expand=True) + + def start_restore(): + selected = [k for k, v in restore_vars.items() if v.get()] + if not selected: + messagebox.showwarning("No Selection", "Please select at least one data type to restore.") + return + + # Confirm restore + if not messagebox.askyesno("Confirm Restore", + f"This will create new records in {self.company_name}.\n\n" + f"Data types: {', '.join(selected)}\n\n" + "Continue?"): + return + + restore_btn.config(state=tk.DISABLED) + + def restore_thread(): + results = {'success': 0, 'failed': 0, 'skipped': 0} + + try: + total_records = sum(len(backup_data['data'].get(k, [])) for k in selected) + processed = 0 + + for data_type in selected: + records = backup_data['data'].get(data_type, []) + + dialog.after(0, lambda t=data_type: ( + progress_label.configure(text=f"Restoring {t}..."), + results_text.insert(tk.END, f"\n--- Restoring {t} ---\n"), + results_text.see(tk.END) + )) + + for record in records: + processed += 1 + progress = int((processed / total_records) * 100) if total_records > 0 else 100 + + dialog.after(0, lambda p=progress, c=processed, t=total_records: ( + progress_bar.configure(value=p), + status_label.configure(text=f"Processing {c} of {t}") + )) + + try: + # Prepare record for creation (remove read-only fields) + create_data = self._prepare_record_for_create(data_type, record) + + if create_data: + # Create the record using the appropriate method + entity_name = data_type.rstrip('s').title() + if data_type == 'classes': + entity_name = 'Class' + elif data_type == 'paymentmethods': + entity_name = 'PaymentMethod' + + # Use _make_request to create the entity + self.qbo_client._make_request('POST', entity_name.lower(), json=create_data) + results['success'] += 1 + + record_name = create_data.get('Name', create_data.get('DisplayName', 'Unknown')) + dialog.after(0, lambda n=record_name: ( + results_text.insert(tk.END, f" ✓ Created: {n}\n"), + results_text.see(tk.END) + )) + else: + results['skipped'] += 1 + + except Exception as e: + results['failed'] += 1 + error_msg = str(e)[:80] + dialog.after(0, lambda em=error_msg: ( + results_text.insert(tk.END, f" ✗ Failed: {em}\n"), + results_text.see(tk.END) + )) + + # Complete + dialog.after(0, lambda: ( + progress_bar.configure(value=100), + progress_label.configure(text="Restore completed!"), + status_label.configure(text=f"Success: {results['success']}, Failed: {results['failed']}, Skipped: {results['skipped']}"), + restore_btn.configure(state=tk.NORMAL), + results_text.insert(tk.END, f"\n=== RESTORE COMPLETE ===\n" + f"Success: {results['success']}\n" + f"Failed: {results['failed']}\n" + f"Skipped: {results['skipped']}\n"), + results_text.see(tk.END) + )) + + logger.info(f"[RESTORE] Completed - Success: {results['success']}, Failed: {results['failed']}, Skipped: {results['skipped']}") + + except Exception as e: + dialog.after(0, lambda: ( + progress_label.configure(text="Restore failed!"), + status_label.configure(text=str(e)), + restore_btn.configure(state=tk.NORMAL), + messagebox.showerror("Restore Failed", f"Error during restore: {str(e)}") + )) + logger.error(f"[RESTORE] Failed: {e}") + + threading.Thread(target=restore_thread, daemon=True).start() + + # Buttons + action_frame = ttk.Frame(dialog) + action_frame.pack(fill=tk.X, padx=15, pady=10) + + restore_btn = ttk.Button(action_frame, text="Start Restore", command=start_restore, width=15) + restore_btn.pack(side=tk.LEFT, padx=5) + + ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5) + + def _prepare_record_for_create(self, data_type: str, record: dict) -> dict: + """Prepare a backup record for creation by removing read-only fields.""" + if not record: + return None + + # Fields to always remove (read-only or system-generated) + remove_fields = [ + 'Id', 'SyncToken', 'MetaData', 'domain', 'sparse', + 'time', 'status', 'FullyQualifiedName', 'Level', + 'CurrentBalance', 'CurrentBalanceWithSubAccounts', + 'Balance', 'BalanceWithJobs', 'OpenBalanceDate', + ] + + # Create a copy without read-only fields + create_data = {} + for key, value in record.items(): + if key not in remove_fields and value is not None: + create_data[key] = value + + # Handle specific entity types + if data_type == 'accounts': + # Remove sub-account reference if parent doesn't exist + if 'ParentRef' in create_data: + del create_data['ParentRef'] + create_data['SubAccount'] = False + + elif data_type in ['customers', 'vendors']: + # Remove parent reference for sub-customers/vendors + if 'ParentRef' in create_data: + del create_data['ParentRef'] + if 'Job' in create_data: + create_data['Job'] = False + + return create_data if create_data.get('Name') or create_data.get('DisplayName') else None + + def _export_qbo_to_excel(self): + """Export QuickBooks Online data to Excel file.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + if self.connection_type != 'qbo': + messagebox.showwarning("QBO Only", "Excel export is currently only supported for QuickBooks Online.") + return + + # Show export dialog + dialog = tk.Toplevel(self.root) + dialog.title("Export QBO Data to Excel") + dialog.geometry("450x400") + dialog.transient(self.root) + dialog.grab_set() + + ttk.Label(dialog, text="Select data types to export:", font=('Segoe UI', 11, 'bold')).pack(pady=(15, 10)) + + # Data type checkboxes + data_types = [ + ('accounts', 'Chart of Accounts'), + ('customers', 'Customers'), + ('vendors', 'Vendors'), + ('items', 'Items/Products & Services'), + ('employees', 'Employees'), + ('classes', 'Classes'), + ] + + export_vars = {} + check_frame = ttk.Frame(dialog) + check_frame.pack(fill=tk.X, padx=30, pady=10) + + for i, (key, label) in enumerate(data_types): + var = tk.BooleanVar(value=i < 4) # First 4 selected by default + export_vars[key] = var + cb = ttk.Checkbutton(check_frame, text=label, variable=var) + cb.grid(row=i // 2, column=i % 2, sticky='w', padx=10, pady=3) + + # Progress + progress_frame = ttk.Frame(dialog) + progress_frame.pack(fill=tk.X, padx=30, pady=20) + + progress_label = ttk.Label(progress_frame, text="Ready to export") + progress_label.pack(anchor=tk.W) + + progress_bar = ttk.Progressbar(progress_frame, mode='determinate', length=350) + progress_bar.pack(fill=tk.X, pady=5) + + def start_export(): + selected = [k for k, v in export_vars.items() if v.get()] + if not selected: + messagebox.showwarning("No Selection", "Please select at least one data type to export.") + return + + # Get save location + safe_company = "".join(c for c in self.company_name if c.isalnum() or c in " -_").strip() + file_path = filedialog.asksaveasfilename( + title="Save Excel File", + defaultextension=".xlsx", + filetypes=[("Excel Files", "*.xlsx"), ("All Files", "*.*")], + initialfile=f"qbo_export_{safe_company}_{datetime.now().strftime('%Y%m%d')}.xlsx" + ) + + if not file_path: + return + + export_btn.config(state=tk.DISABLED) + + def export_thread(): + try: + import openpyxl + + wb = openpyxl.Workbook() + wb.remove(wb.active) # Remove default sheet + + entity_map = { + 'accounts': 'Account', + 'customers': 'Customer', + 'vendors': 'Vendor', + 'items': 'Item', + 'employees': 'Employee', + 'classes': 'Class', + } + + total_steps = len(selected) + current_step = 0 + total_records = 0 + + for data_type in selected: + current_step += 1 + progress = int((current_step / total_steps) * 100) + + dialog.after(0, lambda p=progress, t=data_type: ( + progress_bar.configure(value=p), + progress_label.configure(text=f"Exporting {t}...") + )) + + try: + query_name = entity_map.get(data_type, data_type.title()) + + # Query using _make_request + query = f"SELECT * FROM {query_name} MAXRESULTS 1000" + response = self.qbo_client._make_request('GET', 'query', params={'query': query}) + + if response and 'QueryResponse' in response: + entities = response['QueryResponse'].get(query_name, []) + else: + entities = [] + + if entities: + # Create worksheet + ws = wb.create_sheet(title=data_type.title()[:31]) # Max 31 chars + + # Get all unique keys from entities + all_keys = set() + for entity in entities: + all_keys.update(entity.keys()) + + # Sort keys for consistent order + headers = sorted(all_keys) + + # Write headers + for col, header in enumerate(headers, 1): + ws.cell(row=1, column=col, value=header) + + # Write data + for row_idx, entity in enumerate(entities, 2): + for col_idx, header in enumerate(headers, 1): + value = entity.get(header, '') + # Convert dicts/lists to string + if isinstance(value, (dict, list)): + value = json.dumps(value) + ws.cell(row=row_idx, column=col_idx, value=value) + + total_records += len(entities) + logger.info(f"[EXPORT] Exported {len(entities)} {data_type}") + + except Exception as e: + logger.warning(f"[EXPORT] Failed to export {data_type}: {e}") + + # Save workbook + wb.save(file_path) + + dialog.after(0, lambda: ( + progress_bar.configure(value=100), + progress_label.configure(text="Export completed!"), + export_btn.configure(state=tk.NORMAL), + messagebox.showinfo("Export Complete", + f"Successfully exported {total_records} records to Excel.\n\n" + f"File saved to:\n{file_path}") + )) + + logger.info(f"[EXPORT] Completed Excel export to {file_path} - {total_records} total records") + + except ImportError: + dialog.after(0, lambda: ( + export_btn.configure(state=tk.NORMAL), + messagebox.showerror("Missing Package", + "openpyxl is required for Excel export.\n\n" + "Please install it with:\n" + " pip install openpyxl") + )) + except Exception as e: + dialog.after(0, lambda: ( + progress_label.configure(text="Export failed!"), + export_btn.configure(state=tk.NORMAL), + messagebox.showerror("Export Failed", f"Error during export: {str(e)}") + )) + logger.error(f"[EXPORT] Failed: {e}") + + threading.Thread(target=export_thread, daemon=True).start() + + # Buttons + action_frame = ttk.Frame(dialog) + action_frame.pack(fill=tk.X, padx=30, pady=15) + + export_btn = ttk.Button(action_frame, text="Export to Excel", command=start_export, width=15) + export_btn.pack(side=tk.LEFT, padx=5) + + ttk.Button(action_frame, text="Close", command=dialog.destroy, width=10).pack(side=tk.RIGHT, padx=5) + def _show_logs(self): """Show log viewer dialog.""" dialog = tk.Toplevel(self.root)