diff --git a/data/templates/LT Payroll.json b/data/templates/LT Payroll.json index 5cf7b00..b8989bf 100644 --- a/data/templates/LT Payroll.json +++ b/data/templates/LT Payroll.json @@ -2,33 +2,12 @@ "name": "LT Payroll", "data_type": "check", "mappings": [ - { - "excel_column": "Payee", - "qbo_field": "EntityRef.value", - "transform": null, - "default_value": null, - "required": true - }, { "excel_column": "Bank Acct", "qbo_field": "AccountRef.value", "transform": null, "default_value": null, - "required": true - }, - { - "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 + "required": false }, { "excel_column": "Number", @@ -37,6 +16,20 @@ "default_value": null, "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", "qbo_field": "Line.AccountBasedExpenseLineDetail.AccountRef.value", @@ -45,8 +38,15 @@ "required": false }, { - "excel_column": "Period Memo", - "qbo_field": "PrivateNote", + "excel_column": "Amount", + "qbo_field": "Line.Amount", + "transform": null, + "default_value": null, + "required": false + }, + { + "excel_column": "Period", + "qbo_field": "Line.Description", "transform": null, "default_value": null, "required": false @@ -59,6 +59,6 @@ "required": false } ], - "created_at": "2026-02-12T12:39:07.304264", - "updated_at": "2026-02-12T12:39:07.304281" + "created_at": "2026-02-16T20:16:04.745245", + "updated_at": "2026-02-16T20:16:04.745272" } \ No newline at end of file diff --git a/desktop_app.py b/desktop_app.py new file mode 100644 index 0000000..2ddb0b0 --- /dev/null +++ b/desktop_app.py @@ -0,0 +1,2662 @@ +#!/usr/bin/env python3 +""" +QBO Excel Sync - Desktop Application +A Tkinter-based desktop application for importing Excel data to QuickBooks. +Supports both QuickBooks Online (QBO) and QuickBooks Desktop (QBD). +""" + +import os +import sys +import json +import logging +import threading +import webbrowser +import subprocess +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict, Any, List, Callable +from dataclasses import dataclass + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext + +# Add the project root to path +PROJECT_ROOT = Path(__file__).parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# Import project modules +from src.config.settings import Settings, ImportSettings, QBOCredentials +from src.api.qbo_client import QBOClient +from src.core.excel_parser import ExcelParser, ParseResult +from src.core.import_processor import ImportProcessor, ImportResult + +# Try to import QBD client (Windows only) +try: + from src.api.qbd_client import QBDesktopClient, QBDCredentials, QBDesktopError, check_qbd_availability + HAS_QBD = True +except ImportError: + HAS_QBD = False + QBDesktopClient = None + QBDCredentials = None + QBDesktopError = Exception + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('desktop_app.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Application Constants +# ============================================================================= + +APP_NAME = "QBO Excel Sync" +APP_VERSION = "1.0.0" +WINDOW_WIDTH = 1200 +WINDOW_HEIGHT = 800 +MIN_WIDTH = 900 +MIN_HEIGHT = 600 + +# Supported data types +DATA_TYPES = [ + ("Check", "check"), + ("Invoice", "invoice"), + ("Bill", "bill"), + ("Customer", "customer"), + ("Vendor", "vendor"), + ("Account", "account"), +] + +# Color scheme +COLORS = { + 'primary': '#2563eb', + 'primary_dark': '#1d4ed8', + 'success': '#22c55e', + 'warning': '#f59e0b', + 'danger': '#ef4444', + 'bg': '#f8fafc', + 'card_bg': '#ffffff', + 'text': '#1e293b', + 'text_secondary': '#64748b', + 'border': '#e2e8f0', +} + + +# ============================================================================= +# Custom Widgets +# ============================================================================= + +class ModernButton(ttk.Button): + """A modern styled button.""" + pass + + +class StatusBar(ttk.Frame): + """Status bar widget showing connection status.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + self.status_label = ttk.Label(self, text="Not Connected", font=('Segoe UI', 9)) + self.status_label.pack(side=tk.LEFT, padx=10) + + self.connection_indicator = ttk.Label(self, text="●", foreground='red', font=('Segoe UI', 12)) + self.connection_indicator.pack(side=tk.LEFT) + + self.company_label = ttk.Label(self, text="", font=('Segoe UI', 9, 'italic')) + self.company_label.pack(side=tk.LEFT, padx=10) + + self.version_label = ttk.Label(self, text=f"v{APP_VERSION}", font=('Segoe UI', 8)) + self.version_label.pack(side=tk.RIGHT, padx=10) + + def set_connected(self, company_name: str, connection_type: str = 'qbo'): + """Update status to connected.""" + type_text = "Desktop" if connection_type == 'qbd' else "Online" + self.status_label.config(text=f"Connected ({type_text})") + self.connection_indicator.config(foreground='green') + self.company_label.config(text=company_name) + + def set_disconnected(self): + """Update status to disconnected.""" + self.status_label.config(text="Not Connected") + self.connection_indicator.config(foreground='red') + self.company_label.config(text="") + + +class LogViewer(ttk.Frame): + """A log viewer widget with scrollable text.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + self.text = scrolledtext.ScrolledText( + self, + wrap=tk.WORD, + font=('Consolas', 9), + state=tk.DISABLED, + height=8 + ) + self.text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Configure tags for different log levels + self.text.tag_configure('INFO', foreground='black') + self.text.tag_configure('SUCCESS', foreground='green') + self.text.tag_configure('WARNING', foreground='orange') + self.text.tag_configure('ERROR', foreground='red') + + def log(self, message: str, level: str = 'INFO'): + """Add a log message.""" + self.text.config(state=tk.NORMAL) + timestamp = datetime.now().strftime('%H:%M:%S') + self.text.insert(tk.END, f"[{timestamp}] {message}\n", level) + self.text.see(tk.END) + self.text.config(state=tk.DISABLED) + + def clear(self): + """Clear all log messages.""" + self.text.config(state=tk.NORMAL) + self.text.delete(1.0, tk.END) + self.text.config(state=tk.DISABLED) + + +class ProgressFrame(ttk.Frame): + """A frame showing import progress.""" + + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + + self.label = ttk.Label(self, text="Ready", font=('Segoe UI', 10)) + self.label.pack(fill=tk.X, padx=5, pady=2) + + self.progress = ttk.Progressbar(self, mode='determinate', length=400) + self.progress.pack(fill=tk.X, padx=5, pady=2) + + self.detail_label = ttk.Label(self, text="", font=('Segoe UI', 9), foreground='gray') + self.detail_label.pack(fill=tk.X, padx=5, pady=2) + + def set_progress(self, value: int, maximum: int, text: str = "", detail: str = ""): + """Update progress.""" + self.progress['maximum'] = maximum + self.progress['value'] = value + if text: + self.label.config(text=text) + if detail: + self.detail_label.config(text=detail) + + def reset(self): + """Reset progress.""" + self.progress['value'] = 0 + self.label.config(text="Ready") + self.detail_label.config(text="") + + +# ============================================================================= +# Main Application +# ============================================================================= + +class QBOExcelSyncApp: + """Main application class.""" + + def __init__(self): + self.root = tk.Tk() + self.root.title(APP_NAME) + self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") + self.root.minsize(MIN_WIDTH, MIN_HEIGHT) + + # Maximize window on startup (works on Windows) + try: + self.root.state('zoomed') # Windows + except: + try: + # Linux/Mac alternative + self.root.attributes('-zoomed', True) + except: + pass # Fall back to default size + + # Application state + self.settings = Settings() + self.qbo_client: Optional[QBOClient] = None + self.qbd_client: Optional[QBDesktopClient] = None + self.connection_type: str = 'qbo' # 'qbo' or 'qbd' + self.is_connected: bool = False + self.company_name: str = "" + + # Current import state + self.current_file: Optional[Path] = None + self.parse_result: Optional[ParseResult] = None + self.field_mappings: Dict[str, str] = {} + self.excel_columns: List[str] = [] + self.excel_data: List[Dict[str, Any]] = [] + self.parser: Optional[ExcelParser] = None + + # Setup UI + self._setup_styles() + self._create_menu() + self._create_main_layout() + self._create_status_bar() + + # Try to restore connection + self._try_restore_connection() + + # Bind window close event + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + logger.info("Application started") + + def _setup_styles(self): + """Configure ttk styles.""" + style = ttk.Style() + + # Use a modern theme if available + available_themes = style.theme_names() + if 'clam' in available_themes: + style.theme_use('clam') + elif 'vista' in available_themes: + style.theme_use('vista') + + # Configure custom styles + style.configure('Title.TLabel', font=('Segoe UI', 16, 'bold')) + style.configure('Subtitle.TLabel', font=('Segoe UI', 11)) + style.configure('Header.TLabel', font=('Segoe UI', 12, 'bold')) + style.configure('Card.TFrame', background='white', relief='solid', borderwidth=1) + style.configure('Primary.TButton', font=('Segoe UI', 10)) + style.configure('Success.TLabel', foreground='green') + style.configure('Error.TLabel', foreground='red') + style.configure('Warning.TLabel', foreground='orange') + + def _create_menu(self): + """Create the application menu bar.""" + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + # File menu + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="File", menu=file_menu) + file_menu.add_command(label="Open Excel File...", command=self._open_file, accelerator="Ctrl+O") + file_menu.add_separator() + file_menu.add_command(label="Exit", command=self._on_close, accelerator="Alt+F4") + + # Connection menu + conn_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Connection", menu=conn_menu) + conn_menu.add_command(label="Connect to QuickBooks Online...", command=self._show_qbo_connect) + if HAS_QBD: + conn_menu.add_command(label="Connect to QuickBooks Desktop...", command=self._connect_qbd) + conn_menu.add_separator() + conn_menu.add_command(label="Disconnect", command=self._disconnect) + conn_menu.add_command(label="Test Connection", command=self._test_connection) + + # Tools menu + tools_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Tools", menu=tools_menu) + tools_menu.add_command(label="Manage Templates...", command=self._show_templates) + tools_menu.add_command(label="Settings...", command=self._show_settings) + tools_menu.add_separator() + tools_menu.add_command(label="View Logs...", command=self._show_logs) + + # Help menu + help_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Help", menu=help_menu) + help_menu.add_command(label="Documentation", command=self._show_help) + help_menu.add_command(label="About", command=self._show_about) + + # Keyboard shortcuts + self.root.bind('', lambda e: self._open_file()) + + def _create_main_layout(self): + """Create the main application layout.""" + # Main container + self.main_frame = ttk.Frame(self.root, padding=10) + self.main_frame.pack(fill=tk.BOTH, expand=True) + + # Create notebook for tabs + self.notebook = ttk.Notebook(self.main_frame) + self.notebook.pack(fill=tk.BOTH, expand=True) + + # Create tabs + self._create_dashboard_tab() + self._create_import_tab() + self._create_connection_tab() + self._create_templates_tab() + + def _create_dashboard_tab(self): + """Create the dashboard tab.""" + tab = ttk.Frame(self.notebook, padding=20) + self.notebook.add(tab, text=" Dashboard ") + + # Welcome header + header_frame = ttk.Frame(tab) + header_frame.pack(fill=tk.X, pady=(0, 20)) + + ttk.Label( + header_frame, + text="Welcome to QBO Excel Sync", + style='Title.TLabel' + ).pack(anchor=tk.W) + + ttk.Label( + header_frame, + text="Import your Excel data to QuickBooks with ease.", + style='Subtitle.TLabel', + foreground='gray' + ).pack(anchor=tk.W, pady=(5, 0)) + + # Stats cards frame + stats_frame = ttk.Frame(tab) + stats_frame.pack(fill=tk.X, pady=10) + + # Connection status card + self.conn_card = self._create_stat_card( + stats_frame, + "Connection Status", + "Not Connected", + "Connect to QuickBooks to start" + ) + 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, + "Supported Types", + "6 Types", + "Checks, Invoices, Bills, & more" + ) + types_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(10, 0)) + + # Quick actions + actions_frame = ttk.LabelFrame(tab, text="Quick Actions", padding=15) + actions_frame.pack(fill=tk.X, pady=20) + + actions_inner = ttk.Frame(actions_frame) + actions_inner.pack(fill=tk.X) + + ttk.Button( + actions_inner, + text="📁 Import Excel File", + command=lambda: self.notebook.select(1), # Switch to Import tab + 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 + 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 + width=25 + ).pack(side=tk.LEFT, padx=5) + + # Supported data types + types_frame = ttk.LabelFrame(tab, text="Supported Data Types", padding=15) + types_frame.pack(fill=tk.BOTH, expand=True, pady=10) + + types_grid = ttk.Frame(types_frame) + types_grid.pack(fill=tk.BOTH, expand=True) + + type_info = [ + ("✓ Checks", "Import check payments with payee, amount, and account details."), + ("✓ Invoices", "Create invoices with customer, items, and payment terms."), + ("✓ Bills", "Import vendor bills with expense accounts and due dates."), + ("✓ Customers", "Add new customers with contact and address information."), + ("✓ Vendors", "Import vendor records with payment and tax details."), + ("✓ Accounts", "Create accounts with types, numbers, and descriptions."), + ] + + for i, (title, desc) in enumerate(type_info): + row = i // 3 + col = i % 3 + + type_card = ttk.Frame(types_grid, padding=10) + type_card.grid(row=row, column=col, sticky='nsew', padx=5, pady=5) + + ttk.Label(type_card, text=title, font=('Segoe UI', 10, 'bold')).pack(anchor=tk.W) + ttk.Label(type_card, text=desc, wraplength=200, foreground='gray').pack(anchor=tk.W) + + types_grid.columnconfigure(col, weight=1) + + def _create_stat_card(self, parent, title: str, value: str, detail: str) -> ttk.Frame: + """Create a statistics card widget.""" + card = ttk.Frame(parent, padding=15, relief='solid', borderwidth=1) + + ttk.Label(card, text=title, font=('Segoe UI', 9), foreground='gray').pack(anchor=tk.W) + value_label = ttk.Label(card, text=value, font=('Segoe UI', 14, 'bold')) + value_label.pack(anchor=tk.W, pady=(5, 0)) + detail_label = ttk.Label(card, text=detail, font=('Segoe UI', 9), foreground='gray') + detail_label.pack(anchor=tk.W) + + # Store references for updating + card.value_label = value_label + card.detail_label = detail_label + + return card + + def _create_import_tab(self): + """Create the import tab.""" + tab = ttk.Frame(self.notebook, padding=20) + self.notebook.add(tab, text=" Import ") + + # Left panel - File selection and options + left_frame = ttk.Frame(tab) + left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) + + # File selection + file_frame = ttk.LabelFrame(left_frame, text="1. Select Excel File", padding=15) + file_frame.pack(fill=tk.X, pady=(0, 10)) + + file_inner = ttk.Frame(file_frame) + file_inner.pack(fill=tk.X) + + self.file_entry = ttk.Entry(file_inner, width=50) + self.file_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10)) + + ttk.Button(file_inner, text="Browse...", command=self._open_file).pack(side=tk.LEFT) + ttk.Button(file_inner, text="Refresh", command=self._refresh_file).pack(side=tk.LEFT, padx=(5, 0)) + + self.file_info_label = ttk.Label(file_frame, text="No file selected", foreground='gray') + self.file_info_label.pack(anchor=tk.W, pady=(10, 0)) + + # Data type and template selection + type_frame = ttk.LabelFrame(left_frame, text="2. Select Data Type & Template", padding=15) + type_frame.pack(fill=tk.X, pady=10) + + # Data type radio buttons + self.data_type_var = tk.StringVar(value="check") + + type_grid = ttk.Frame(type_frame) + type_grid.pack(fill=tk.X) + + for i, (label, value) in enumerate(DATA_TYPES): + rb = ttk.Radiobutton( + type_grid, + text=label, + variable=self.data_type_var, + value=value, + command=self._on_data_type_change + ) + rb.grid(row=i // 3, column=i % 3, sticky=tk.W, padx=10, pady=5) + + # Template selection + template_row = ttk.Frame(type_frame) + template_row.pack(fill=tk.X, pady=(10, 0)) + + ttk.Label(template_row, text="Template:").pack(side=tk.LEFT) + + self.template_var = tk.StringVar(value="") + self.template_combo = ttk.Combobox( + template_row, + textvariable=self.template_var, + values=["(Default Template)"], + width=30, + state='readonly' + ) + self.template_combo.pack(side=tk.LEFT, padx=(10, 0)) + self.template_combo.current(0) + self.template_combo.bind('<>', self._on_template_change) + + ttk.Button(template_row, text="↻", command=self._refresh_template_list, width=3).pack(side=tk.LEFT, padx=(5, 0)) + + # Field mapping + mapping_frame = ttk.LabelFrame(left_frame, text="3. Field Mapping", padding=15) + mapping_frame.pack(fill=tk.BOTH, expand=True, pady=10) + + # Mapping treeview + mapping_inner = ttk.Frame(mapping_frame) + mapping_inner.pack(fill=tk.BOTH, expand=True) + + columns = ('excel_column', 'qbo_field') + self.mapping_tree = ttk.Treeview(mapping_inner, columns=columns, show='headings', height=10) + self.mapping_tree.heading('excel_column', text='Excel Column') + self.mapping_tree.heading('qbo_field', text='QuickBooks Field') + self.mapping_tree.column('excel_column', width=200) + self.mapping_tree.column('qbo_field', width=200) + + mapping_scroll = ttk.Scrollbar(mapping_inner, orient=tk.VERTICAL, command=self.mapping_tree.yview) + self.mapping_tree.configure(yscrollcommand=mapping_scroll.set) + + self.mapping_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + mapping_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + mapping_buttons = ttk.Frame(mapping_frame) + mapping_buttons.pack(fill=tk.X, pady=(10, 0)) + + ttk.Button(mapping_buttons, text="Auto-Map", command=self._auto_map_fields).pack(side=tk.LEFT) + ttk.Button(mapping_buttons, text="Edit Mapping...", command=self._edit_mapping).pack(side=tk.LEFT, padx=5) + ttk.Button(mapping_buttons, text="Save as Template", command=self._save_template).pack(side=tk.LEFT) + + # Right panel - Preview and import + right_frame = ttk.Frame(tab) + right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0)) + + # Data preview + preview_frame = ttk.LabelFrame(right_frame, text="Data Preview", padding=15) + preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + self.preview_tree = ttk.Treeview(preview_frame, show='headings', height=10) + preview_scroll_y = ttk.Scrollbar(preview_frame, orient=tk.VERTICAL, command=self.preview_tree.yview) + preview_scroll_x = ttk.Scrollbar(preview_frame, orient=tk.HORIZONTAL, command=self.preview_tree.xview) + self.preview_tree.configure(yscrollcommand=preview_scroll_y.set, xscrollcommand=preview_scroll_x.set) + + self.preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y) + + # Progress and import + import_frame = ttk.LabelFrame(right_frame, text="Import", padding=15) + import_frame.pack(fill=tk.X, pady=10) + + self.progress_frame = ProgressFrame(import_frame) + self.progress_frame.pack(fill=tk.X, pady=(0, 10)) + + import_buttons = ttk.Frame(import_frame) + import_buttons.pack(fill=tk.X) + + self.import_btn = ttk.Button( + import_buttons, + text="▶ Start Import", + command=self._start_import, + width=20 + ) + self.import_btn.pack(side=tk.LEFT) + + self.stop_btn = ttk.Button( + import_buttons, + text="■ Stop", + command=self._stop_import, + state=tk.DISABLED, + width=10 + ) + self.stop_btn.pack(side=tk.LEFT, padx=10) + + # Log viewer + log_frame = ttk.LabelFrame(right_frame, text="Import Log", padding=10) + log_frame.pack(fill=tk.BOTH, expand=True) + + self.log_viewer = LogViewer(log_frame) + self.log_viewer.pack(fill=tk.BOTH, expand=True) + + def _create_connection_tab(self): + """Create the connection tab.""" + tab = ttk.Frame(self.notebook, padding=20) + self.notebook.add(tab, text=" Connection ") + + # Connection type selection + type_frame = ttk.LabelFrame(tab, text="Connection Type", padding=15) + type_frame.pack(fill=tk.X, pady=(0, 20)) + + self.conn_type_var = tk.StringVar(value='qbo') + + qbo_rb = ttk.Radiobutton( + type_frame, + text="QuickBooks Online", + variable=self.conn_type_var, + value='qbo', + command=self._on_conn_type_change + ) + qbo_rb.pack(anchor=tk.W, pady=5) + + qbd_rb = ttk.Radiobutton( + type_frame, + text="QuickBooks Desktop" + ("" if HAS_QBD else " (Not Available)"), + variable=self.conn_type_var, + value='qbd', + state=tk.NORMAL if HAS_QBD else tk.DISABLED, + command=self._on_conn_type_change + ) + qbd_rb.pack(anchor=tk.W, pady=5) + + # QBO Connection Frame + self.qbo_frame = ttk.LabelFrame(tab, text="QuickBooks Online Connection", padding=15) + self.qbo_frame.pack(fill=tk.X, pady=10) + + # API Credentials + creds_frame = ttk.Frame(self.qbo_frame) + creds_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(creds_frame, text="Client ID:").grid(row=0, column=0, sticky=tk.W, pady=5) + self.client_id_entry = ttk.Entry(creds_frame, width=50) + self.client_id_entry.grid(row=0, column=1, sticky=tk.W, padx=10, pady=5) + + ttk.Label(creds_frame, text="Client Secret:").grid(row=1, column=0, sticky=tk.W, pady=5) + self.client_secret_entry = ttk.Entry(creds_frame, width=50, show='*') + self.client_secret_entry.grid(row=1, column=1, sticky=tk.W, padx=10, pady=5) + + ttk.Label(creds_frame, text="Redirect URI:").grid(row=2, column=0, sticky=tk.W, pady=5) + self.redirect_uri_entry = ttk.Entry(creds_frame, width=50) + self.redirect_uri_entry.insert(0, "http://localhost:5000/oauth/callback") + self.redirect_uri_entry.grid(row=2, column=1, sticky=tk.W, padx=10, pady=5) + + ttk.Label(creds_frame, text="Environment:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.env_var = tk.StringVar(value=self.settings.qbo_environment) + env_combo = ttk.Combobox(creds_frame, textvariable=self.env_var, values=['sandbox', 'production'], width=15) + env_combo.grid(row=3, column=1, sticky=tk.W, padx=10, pady=5) + + # QBO buttons + qbo_buttons = ttk.Frame(self.qbo_frame) + qbo_buttons.pack(fill=tk.X, pady=10) + + ttk.Button(qbo_buttons, text="Save Credentials", command=self._save_qbo_credentials, width=20).pack(side=tk.LEFT) + ttk.Button(qbo_buttons, text="Connect to QuickBooks", command=self._start_oauth, width=25).pack(side=tk.LEFT, padx=10) + + # Advanced options in a second row + qbo_buttons2 = ttk.Frame(self.qbo_frame) + qbo_buttons2.pack(fill=tk.X, pady=(5, 0)) + + ttk.Label(qbo_buttons2, text="Advanced:", foreground='gray').pack(side=tk.LEFT) + ttk.Button(qbo_buttons2, text="Enter Code Manually", command=self._show_auth_code_dialog, width=22).pack(side=tk.LEFT, padx=(10, 5)) + ttk.Button(qbo_buttons2, text="Enter Token Manually", command=self._enter_token_manually, width=22).pack(side=tk.LEFT) + + # QBD Connection Frame + self.qbd_frame = ttk.LabelFrame(tab, text="QuickBooks Desktop Connection", padding=15) + self.qbd_frame.pack(fill=tk.X, pady=10) + + if HAS_QBD: + qbd_info = ttk.Label( + self.qbd_frame, + text="Connect to QuickBooks Desktop running on this computer.\n" + "Make sure QuickBooks Desktop is open with a company file.", + foreground='gray' + ) + else: + qbd_info = ttk.Label( + self.qbd_frame, + text="QuickBooks Desktop integration is not available.\n" + "This feature requires Windows and pywin32.", + foreground='red' + ) + qbd_info.pack(anchor=tk.W, pady=(0, 10)) + + qbd_buttons = ttk.Frame(self.qbd_frame) + qbd_buttons.pack(fill=tk.X) + + self.qbd_connect_btn = ttk.Button( + qbd_buttons, + text="Connect to QuickBooks Desktop", + command=self._connect_qbd, + state=tk.NORMAL if HAS_QBD else tk.DISABLED + ) + self.qbd_connect_btn.pack(side=tk.LEFT) + + # Connection status + status_frame = ttk.LabelFrame(tab, text="Connection Status", padding=15) + status_frame.pack(fill=tk.X, pady=20) + + self.conn_status_label = ttk.Label( + status_frame, + text="● Not Connected", + font=('Segoe UI', 11) + ) + self.conn_status_label.pack(anchor=tk.W) + + self.conn_company_label = ttk.Label( + status_frame, + text="", + font=('Segoe UI', 10), + foreground='gray' + ) + self.conn_company_label.pack(anchor=tk.W, pady=(5, 0)) + + conn_actions = ttk.Frame(status_frame) + conn_actions.pack(fill=tk.X, pady=(15, 0)) + + test_btn = ttk.Button(conn_actions, text="Test Connection", command=self._test_connection, width=15) + test_btn.pack(side=tk.LEFT) + + disconnect_btn = ttk.Button(conn_actions, text="Disconnect", command=self._disconnect, width=12) + disconnect_btn.pack(side=tk.LEFT, padx=10) + + # Load saved credentials + self._load_saved_credentials() + + def _create_templates_tab(self): + """Create the templates management tab.""" + tab = ttk.Frame(self.notebook, padding=20) + self.notebook.add(tab, text=" Templates ") + + # Header + header = ttk.Frame(tab) + header.pack(fill=tk.X, pady=(0, 20)) + + ttk.Label(header, text="Field Mapping Templates", style='Title.TLabel').pack(side=tk.LEFT) + ttk.Button(header, text="+ New Template", command=self._create_template).pack(side=tk.RIGHT) + + # Templates list + list_frame = ttk.Frame(tab) + list_frame.pack(fill=tk.BOTH, expand=True) + + columns = ('name', 'data_type', 'created', 'mappings') + self.templates_tree = ttk.Treeview(list_frame, columns=columns, show='headings', height=15) + self.templates_tree.heading('name', text='Template Name') + self.templates_tree.heading('data_type', text='Data Type') + self.templates_tree.heading('created', text='Created') + self.templates_tree.heading('mappings', text='Mappings') + + templates_scroll = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.templates_tree.yview) + self.templates_tree.configure(yscrollcommand=templates_scroll.set) + + self.templates_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + templates_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + # Template actions + actions = ttk.Frame(tab) + actions.pack(fill=tk.X, pady=(15, 0)) + + ttk.Button(actions, text="Edit", command=self._edit_template).pack(side=tk.LEFT) + ttk.Button(actions, text="Duplicate", command=self._duplicate_template).pack(side=tk.LEFT, padx=5) + ttk.Button(actions, text="Delete", command=self._delete_template).pack(side=tk.LEFT) + ttk.Button(actions, text="Refresh", command=self._refresh_templates).pack(side=tk.RIGHT) + + # Load templates + self._refresh_templates() + + def _create_status_bar(self): + """Create the status bar.""" + self.status_bar = StatusBar(self.root) + self.status_bar.pack(fill=tk.X, side=tk.BOTTOM, padx=10, pady=5) + + # ========================================================================= + # Connection Methods + # ========================================================================= + + def _try_restore_connection(self): + """Try to restore a previous connection.""" + creds = self.settings.get_credentials() + if creds and creds.access_token: + try: + self.qbo_client = QBOClient(creds) + if self.qbo_client.is_authenticated: + company_info = self.qbo_client.get_company_info() + self.is_connected = True + self.connection_type = 'qbo' + self.company_name = company_info.get('CompanyName', 'Unknown') + self._update_connection_ui() + logger.info(f"Restored connection to {self.company_name}") + except Exception as e: + logger.warning(f"Could not restore connection: {e}") + + def _show_qbo_connect(self): + """Show QBO connection dialog.""" + self.notebook.select(2) # Switch to Connection tab + self.conn_type_var.set('qbo') + self._on_conn_type_change() + + def _save_qbo_credentials(self): + """Save QBO API credentials.""" + client_id = self.client_id_entry.get().strip() + client_secret = self.client_secret_entry.get().strip() + redirect_uri = self.redirect_uri_entry.get().strip() + environment = self.env_var.get() + + if not client_id or not client_secret: + messagebox.showwarning("Missing Information", "Please enter Client ID and Client Secret.") + return + + creds = QBOCredentials( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri + ) + + self.settings.qbo_environment = environment + self.settings.save_credentials(creds) + + messagebox.showinfo("Saved", "Credentials saved successfully.") + logger.info("QBO credentials saved") + + def _start_oauth(self): + """Start the OAuth flow.""" + client_id = self.client_id_entry.get().strip() + client_secret = self.client_secret_entry.get().strip() + redirect_uri = self.redirect_uri_entry.get().strip() + + if not client_id or not client_secret: + messagebox.showwarning("Missing Information", "Please enter Client ID and Client Secret first.") + return + + # Start OAuth with automatic callback capture + self._start_oauth_with_callback(client_id, client_secret, redirect_uri) + + def _start_oauth_with_callback(self, client_id: str, client_secret: str, redirect_uri: str): + """Start OAuth flow with automatic callback capture using a local server.""" + import http.server + import socketserver + from urllib.parse import urlencode, urlparse, parse_qs + import socket + + # Parse redirect URI to get port + parsed = urlparse(redirect_uri) + port = parsed.port or 5000 + + # Variables to store callback data + callback_data = {'code': None, 'realm_id': None, 'error': None} + server_instance = [None] # Use list to allow modification in nested function + + class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + """Handle the OAuth callback.""" + try: + parsed_path = urlparse(self.path) + params = parse_qs(parsed_path.query) + + if 'code' in params: + callback_data['code'] = params['code'][0] + callback_data['realm_id'] = params.get('realmId', [None])[0] + + # Send success response + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + success_html = """ + + Authorization Successful + +

✓ Authorization Successful!

+

You can close this window and return to the application.

+ + + + """ + self.wfile.write(success_html.encode()) + elif 'error' in params: + callback_data['error'] = params.get('error_description', params['error'])[0] + + self.send_response(400) + self.send_header('Content-type', 'text/html') + self.end_headers() + + error_html = f""" + + Authorization Failed + +

✗ Authorization Failed

+

{callback_data['error']}

+

Please close this window and try again.

+ + + """ + self.wfile.write(error_html.encode()) + else: + self.send_response(400) + self.end_headers() + except Exception as e: + callback_data['error'] = str(e) + self.send_response(500) + self.end_headers() + + def log_message(self, format, *args): + """Suppress server logs.""" + pass + + def run_server(): + """Run the callback server.""" + try: + # Allow address reuse + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("", port), OAuthCallbackHandler) as httpd: + server_instance[0] = httpd + httpd.handle_request() # Handle one request then stop + except Exception as e: + callback_data['error'] = f"Server error: {str(e)}" + + # Show waiting dialog + dialog = tk.Toplevel(self.root) + dialog.title("Connecting to QuickBooks") + dialog.geometry("450x200") + dialog.transient(self.root) + dialog.grab_set() + dialog.resizable(False, False) + + ttk.Label( + dialog, + text="Waiting for authorization...", + font=('Segoe UI', 12, 'bold') + ).pack(pady=(30, 10)) + + ttk.Label( + dialog, + text="A browser window has opened for QuickBooks authorization.\n" + "Please log in and authorize the application.\n\n" + "This window will close automatically when complete.", + wraplength=400, + justify=tk.CENTER + ).pack(pady=10) + + progress = ttk.Progressbar(dialog, mode='indeterminate', length=300) + progress.pack(pady=20) + progress.start(10) + + cancel_clicked = [False] + + def cancel(): + cancel_clicked[0] = True + if server_instance[0]: + try: + # Create a dummy connection to unblock the server + import socket as sock + s = sock.socket(sock.AF_INET, sock.SOCK_STREAM) + s.settimeout(1) + try: + s.connect(('localhost', port)) + s.close() + except: + pass + except: + pass + dialog.destroy() + + ttk.Button(dialog, text="Cancel", command=cancel, width=15).pack(pady=10) + + # Start server in background thread + import threading + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + # Generate OAuth URL + environment = self.env_var.get() + auth_base = "https://appcenter.intuit.com/connect/oauth2" + + params = { + 'client_id': client_id, + 'response_type': 'code', + 'scope': 'com.intuit.quickbooks.accounting', + 'redirect_uri': redirect_uri, + 'state': 'desktop_app' + } + + auth_url = f"{auth_base}?{urlencode(params)}" + + # Open browser + webbrowser.open(auth_url) + + def check_callback(): + """Check if callback was received.""" + if cancel_clicked[0]: + return + + if callback_data['code']: + # Success - got the code + dialog.destroy() + self._exchange_code_for_token(callback_data['code'], callback_data['realm_id']) + elif callback_data['error']: + # Error + dialog.destroy() + messagebox.showerror("Authorization Failed", callback_data['error']) + elif server_thread.is_alive(): + # Still waiting + self.root.after(100, check_callback) + else: + # Server stopped without getting code + if not cancel_clicked[0]: + dialog.destroy() + messagebox.showerror("Authorization Failed", "No authorization response received.") + + # Start checking for callback + self.root.after(100, check_callback) + + def _show_auth_code_dialog(self): + """Show dialog to enter authorization code manually (fallback).""" + dialog = tk.Toplevel(self.root) + dialog.title("Enter Authorization Code Manually") + dialog.geometry("500x250") + dialog.transient(self.root) + dialog.grab_set() + + ttk.Label( + dialog, + text="If automatic capture didn't work, you can enter the\n" + "authorization code and Realm ID manually:", + wraplength=450, + justify=tk.CENTER + ).pack(padx=20, pady=20) + + code_frame = ttk.Frame(dialog) + code_frame.pack(fill=tk.X, padx=20, pady=5) + ttk.Label(code_frame, text="Authorization Code:", width=18, anchor=tk.W).pack(side=tk.LEFT) + code_entry = ttk.Entry(code_frame, width=40) + code_entry.pack(side=tk.LEFT, padx=10) + + realm_frame = ttk.Frame(dialog) + realm_frame.pack(fill=tk.X, padx=20, pady=5) + ttk.Label(realm_frame, text="Realm ID:", width=18, anchor=tk.W).pack(side=tk.LEFT) + realm_entry = ttk.Entry(realm_frame, width=40) + realm_entry.pack(side=tk.LEFT, padx=10) + + def submit(): + code = code_entry.get().strip() + realm_id = realm_entry.get().strip() + + if not code or not realm_id: + messagebox.showwarning("Missing Information", "Please enter both the authorization code and Realm ID.") + return + + dialog.destroy() + self._exchange_code_for_token(code, realm_id) + + btn_frame = ttk.Frame(dialog) + btn_frame.pack(pady=20) + ttk.Button(btn_frame, text="Connect", command=submit, width=12).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Cancel", command=dialog.destroy, width=12).pack(side=tk.LEFT, padx=5) + + def _exchange_code_for_token(self, code: str, realm_id: str): + """Exchange authorization code for access token.""" + import requests + from base64 import b64encode + + client_id = self.client_id_entry.get().strip() + client_secret = self.client_secret_entry.get().strip() + redirect_uri = self.redirect_uri_entry.get().strip() + + # Token endpoint + token_url = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer" + + # Prepare request + auth_string = b64encode(f"{client_id}:{client_secret}".encode()).decode() + + headers = { + 'Authorization': f'Basic {auth_string}', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri + } + + try: + response = requests.post(token_url, headers=headers, data=data, timeout=30) + + if response.status_code == 200: + token_data = response.json() + + # Save credentials + creds = QBOCredentials( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + access_token=token_data.get('access_token'), + refresh_token=token_data.get('refresh_token'), + realm_id=realm_id, + token_expiry=datetime.now().isoformat() + ) + + self.settings.save_credentials(creds) + + # Connect + self.qbo_client = QBOClient(creds) + company_info = self.qbo_client.get_company_info() + + self.is_connected = True + self.connection_type = 'qbo' + self.company_name = company_info.get('CompanyName', 'Unknown') + + self._update_connection_ui() + + messagebox.showinfo("Connected", f"Successfully connected to {self.company_name}") + logger.info(f"Connected to QBO: {self.company_name}") + else: + error_msg = response.json().get('error_description', response.text) + messagebox.showerror("Connection Failed", f"Failed to get access token: {error_msg}") + logger.error(f"OAuth token exchange failed: {error_msg}") + + except Exception as e: + messagebox.showerror("Error", f"Connection error: {str(e)}") + logger.error(f"OAuth error: {e}") + + def _enter_token_manually(self): + """Show dialog to enter token manually.""" + dialog = tk.Toplevel(self.root) + dialog.title("Enter Token Manually") + dialog.geometry("500x300") + dialog.transient(self.root) + dialog.grab_set() + + ttk.Label(dialog, text="Access Token:").pack(padx=20, pady=(20, 5), anchor=tk.W) + access_entry = ttk.Entry(dialog, width=60) + access_entry.pack(padx=20, pady=5) + + ttk.Label(dialog, text="Refresh Token:").pack(padx=20, pady=(10, 5), anchor=tk.W) + refresh_entry = ttk.Entry(dialog, width=60) + refresh_entry.pack(padx=20, pady=5) + + ttk.Label(dialog, text="Realm ID (Company ID):").pack(padx=20, pady=(10, 5), anchor=tk.W) + realm_entry = ttk.Entry(dialog, width=60) + realm_entry.pack(padx=20, pady=5) + + def submit(): + access_token = access_entry.get().strip() + refresh_token = refresh_entry.get().strip() + realm_id = realm_entry.get().strip() + + if not access_token or not realm_id: + messagebox.showwarning("Missing Information", "Please enter Access Token and Realm ID.") + return + + creds = QBOCredentials( + client_id=self.client_id_entry.get().strip(), + client_secret=self.client_secret_entry.get().strip(), + redirect_uri=self.redirect_uri_entry.get().strip(), + access_token=access_token, + refresh_token=refresh_token, + realm_id=realm_id, + token_expiry=datetime.now().isoformat() + ) + + try: + self.settings.save_credentials(creds) + self.qbo_client = QBOClient(creds) + company_info = self.qbo_client.get_company_info() + + self.is_connected = True + self.connection_type = 'qbo' + self.company_name = company_info.get('CompanyName', 'Unknown') + + self._update_connection_ui() + dialog.destroy() + + messagebox.showinfo("Connected", f"Successfully connected to {self.company_name}") + except Exception as e: + messagebox.showerror("Error", f"Connection failed: {str(e)}") + + ttk.Button(dialog, text="Connect", command=submit).pack(pady=20) + + def _connect_qbd(self): + """Connect to QuickBooks Desktop.""" + if not HAS_QBD: + messagebox.showwarning( + "Not Available", + "QuickBooks Desktop integration is not available.\n" + "This feature requires Windows and pywin32." + ) + return + + self.qbd_connect_btn.config(state=tk.DISABLED, text="Connecting...") + self.root.update() + + def connect_thread(): + try: + # Run connection in subprocess to avoid COM threading issues + connect_script = ''' +import sys +import json +try: + import pythoncom + from win32com.client import Dispatch + + pythoncom.CoInitialize() + + rp = Dispatch("QBXMLRP2.RequestProcessor") + rp.OpenConnection2("", "QBO Excel Sync Desktop", 1) + ticket = rp.BeginSession("", 0) + + # Query company name + request = """ + + + + + +""" + + response = rp.ProcessRequest(ticket, request) + + import xml.etree.ElementTree as ET + root = ET.fromstring(response) + company_elem = root.find(".//CompanyName") + company_name = company_elem.text if company_elem is not None else "QuickBooks Desktop" + + rp.EndSession(ticket) + rp.CloseConnection() + pythoncom.CoUninitialize() + + print(json.dumps({"success": True, "company_name": company_name})) + +except Exception as e: + print(json.dumps({"success": False, "error": str(e)})) + sys.exit(1) +''' + + result = subprocess.run( + [sys.executable, '-c', connect_script], + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode == 0: + output = json.loads(result.stdout.strip()) + if output.get('success'): + self.root.after(0, lambda: self._qbd_connect_success(output.get('company_name', 'QuickBooks Desktop'))) + else: + self.root.after(0, lambda: self._qbd_connect_error(output.get('error', 'Unknown error'))) + else: + self.root.after(0, lambda: self._qbd_connect_error(result.stderr or result.stdout)) + + except subprocess.TimeoutExpired: + self.root.after(0, lambda: self._qbd_connect_error("Connection timed out. Check QuickBooks Desktop.")) + except Exception as e: + self.root.after(0, lambda: self._qbd_connect_error(str(e))) + + threading.Thread(target=connect_thread, daemon=True).start() + + def _qbd_connect_success(self, company_name: str): + """Handle successful QBD connection.""" + self.is_connected = True + self.connection_type = 'qbd' + self.company_name = company_name + + self._update_connection_ui() + self.qbd_connect_btn.config(state=tk.NORMAL, text="Connect to QuickBooks Desktop") + + messagebox.showinfo("Connected", f"Successfully connected to {company_name}") + logger.info(f"Connected to QBD: {company_name}") + + def _qbd_connect_error(self, error: str): + """Handle QBD connection error.""" + self.qbd_connect_btn.config(state=tk.NORMAL, text="Connect to QuickBooks Desktop") + messagebox.showerror("Connection Failed", f"Failed to connect: {error}") + logger.error(f"QBD connection failed: {error}") + + def _disconnect(self): + """Disconnect from QuickBooks.""" + if not self.is_connected: + return + + if messagebox.askyesno("Confirm", "Are you sure you want to disconnect?"): + self.is_connected = False + self.qbo_client = None + self.qbd_client = None + self.company_name = "" + + self._update_connection_ui() + logger.info("Disconnected from QuickBooks") + + def _test_connection(self): + """Test the current connection.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + try: + if self.connection_type == 'qbo' and self.qbo_client: + company_info = self.qbo_client.get_company_info() + customers = self.qbo_client.get_customers() + messagebox.showinfo( + "Connection OK", + f"Connected to: {company_info.get('CompanyName', 'Unknown')}\n" + f"Customers: {len(customers)}" + ) + elif self.connection_type == 'qbd': + messagebox.showinfo("Connection OK", f"Connected to: {self.company_name}") + except Exception as e: + messagebox.showerror("Connection Error", f"Connection test failed: {str(e)}") + + def _update_connection_ui(self): + """Update all UI elements to reflect connection status.""" + if self.is_connected: + # Status bar + self.status_bar.set_connected(self.company_name, self.connection_type) + + # Dashboard card + self.conn_card.value_label.config(text="Connected") + self.conn_card.detail_label.config(text=self.company_name) + + # Connection tab + type_text = "Desktop" if self.connection_type == 'qbd' else "Online" + self.conn_status_label.config(text=f"● Connected ({type_text})", foreground='green') + self.conn_company_label.config(text=self.company_name) + else: + # Status bar + self.status_bar.set_disconnected() + + # Dashboard card + self.conn_card.value_label.config(text="Not Connected") + self.conn_card.detail_label.config(text="Connect to QuickBooks to start") + + # Connection tab + self.conn_status_label.config(text="● Not Connected", foreground='red') + self.conn_company_label.config(text="") + + def _on_conn_type_change(self): + """Handle connection type change.""" + conn_type = self.conn_type_var.get() + if conn_type == 'qbo': + self.qbo_frame.pack(fill=tk.X, pady=10, before=self.qbd_frame) + else: + self.qbo_frame.pack_forget() + + def _load_saved_credentials(self): + """Load saved credentials into the form.""" + creds = self.settings.get_credentials() + if creds: + self.client_id_entry.delete(0, tk.END) + self.client_id_entry.insert(0, creds.client_id or '') + + self.client_secret_entry.delete(0, tk.END) + self.client_secret_entry.insert(0, creds.client_secret or '') + + if creds.redirect_uri: + self.redirect_uri_entry.delete(0, tk.END) + self.redirect_uri_entry.insert(0, creds.redirect_uri) + + # ========================================================================= + # Import Methods + # ========================================================================= + + def _open_file(self): + """Open file dialog to select Excel file.""" + file_path = filedialog.askopenfilename( + title="Select Excel File", + filetypes=[ + ("Excel Files", "*.xlsx *.xls"), + ("All Files", "*.*") + ] + ) + + if file_path: + self.current_file = Path(file_path) + self.file_entry.delete(0, tk.END) + self.file_entry.insert(0, str(self.current_file)) + self._parse_file() + + def _refresh_file(self): + """Refresh the current file.""" + if self.current_file and self.current_file.exists(): + self._parse_file() + else: + messagebox.showwarning("No File", "Please select a file first.") + + def _parse_file(self): + """Parse the selected Excel file.""" + if not self.current_file or not self.current_file.exists(): + return + + try: + parser = ExcelParser() + + # Use preview to load data (returns columns and list of row dicts) + self.excel_columns, self.excel_data = parser.preview( + str(self.current_file), + max_rows=1000 # Load more rows for actual import + ) + + # Store parser for later use + self.parser = parser + + # Update file info + row_count = len(self.excel_data) + self.file_info_label.config( + text=f"Loaded {row_count} rows from {self.current_file.name}", + foreground='green' + ) + + # Update preview + self._update_preview() + + # Update mapping + self._update_mapping() + + logger.info(f"Parsed file: {self.current_file.name} ({row_count} rows)") + + except Exception as e: + self.file_info_label.config(text=f"Error: {str(e)}", foreground='red') + logger.error(f"Error parsing file: {e}") + messagebox.showerror("Parse Error", f"Failed to parse file: {str(e)}") + + def _update_preview(self): + """Update the data preview tree.""" + # Clear existing + for item in self.preview_tree.get_children(): + self.preview_tree.delete(item) + + if not hasattr(self, 'excel_data') or not self.excel_data: + return + + # Get columns + columns = self.excel_columns if hasattr(self, 'excel_columns') else [] + + if not columns and self.excel_data: + # Fallback: get columns from first row + columns = list(self.excel_data[0].keys()) + + # Configure columns + self.preview_tree['columns'] = columns + for col in columns: + self.preview_tree.heading(col, text=col) + self.preview_tree.column(col, width=100) + + # Add rows (limit to 100 for performance) + for row in self.excel_data[:100]: + values = [row.get(col, '') for col in columns] + self.preview_tree.insert('', tk.END, values=values) + + def _update_mapping(self): + """Update the field mapping tree.""" + # Clear existing + for item in self.mapping_tree.get_children(): + self.mapping_tree.delete(item) + + if not hasattr(self, 'excel_columns') or not self.excel_columns: + return + + # Add to tree + for col in self.excel_columns: + qbo_field = self.field_mappings.get(col, '(unmapped)') + self.mapping_tree.insert('', tk.END, values=(col, qbo_field)) + + def _on_data_type_change(self): + """Handle data type selection change.""" + # Refresh template list for new data type + self._refresh_template_list() + # Reset to default template + if hasattr(self, 'template_combo'): + self.template_combo.current(0) + # Re-parse file with new data type + if self.current_file: + self._parse_file() + + def _refresh_template_list(self): + """Refresh the template dropdown with available templates.""" + if not hasattr(self, 'template_combo'): + return + + data_type = self.data_type_var.get() + + # Get templates for current data type + all_templates = self.settings.get_templates(data_type) + + # Build list of template names + template_names = ["(Default Template)"] + for t in all_templates: + if hasattr(t, 'name'): + template_names.append(t.name) + + self.template_combo['values'] = template_names + self.template_combo.current(0) + + def _on_template_change(self, event=None): + """Handle template selection change.""" + selected = self.template_var.get() + + if selected == "(Default Template)" or not selected: + # Use default template - run auto-map + if hasattr(self, 'excel_columns') and self.excel_columns: + self._auto_map_fields() + else: + # Load selected template + self._load_template(selected) + + def _load_template(self, template_name: str): + """Load a saved template.""" + templates = self.settings.get_templates() + + template = None + for t in templates: + if hasattr(t, 'name') and t.name == template_name: + template = t + break + + if not template: + messagebox.showwarning("Template Not Found", f"Template '{template_name}' not found.") + return + + # Convert template mappings to field_mappings dict + self.field_mappings = {} + + if not hasattr(self, 'excel_columns') or not self.excel_columns: + # No file loaded yet, just store template info + for mapping in template.mappings: + if hasattr(mapping, 'excel_column'): + self.field_mappings[mapping.excel_column] = mapping.qbo_field + else: + # Match template columns to actual Excel columns + excel_columns_lower = {col.lower().replace(' ', '').replace('_', ''): col for col in self.excel_columns} + + for mapping in template.mappings: + if hasattr(mapping, 'excel_column'): + template_col = mapping.excel_column + template_col_lower = template_col.lower().replace(' ', '').replace('_', '') + + # Try exact match + if template_col in self.excel_columns: + self.field_mappings[template_col] = mapping.qbo_field + # Try normalized match + elif template_col_lower in excel_columns_lower: + actual_col = excel_columns_lower[template_col_lower] + self.field_mappings[actual_col] = mapping.qbo_field + + self._update_mapping() + logger.info(f"[EDIT] Loaded template: {template_name} ({len(self.field_mappings)} mappings)") + + def _auto_map_fields(self): + """Automatically map fields based on column names using default templates.""" + if not hasattr(self, 'excel_columns') or not self.excel_columns: + messagebox.showwarning("No Data", "Please load a file first.") + return + + data_type = self.data_type_var.get() + excel_columns = self.excel_columns + + # Get default template mappings from settings + default_templates = self.settings.get_default_templates() + template = default_templates.get(data_type) + + if template: + # Use template mappings - match Excel columns to template's excel_column names + self.field_mappings = {} + excel_columns_lower = {col.lower().replace(' ', '').replace('_', ''): col for col in excel_columns} + + for mapping in template.mappings: + # Try to find matching Excel column + template_col_lower = mapping.excel_column.lower().replace(' ', '').replace('_', '') + + # Direct match + if template_col_lower in excel_columns_lower: + self.field_mappings[excel_columns_lower[template_col_lower]] = mapping.qbo_field + else: + # Partial match + for excel_col_lower, excel_col in excel_columns_lower.items(): + if template_col_lower in excel_col_lower or excel_col_lower in template_col_lower: + self.field_mappings[excel_col] = mapping.qbo_field + break + else: + # Fallback to simple common mappings + common_mappings = { + 'check': { + 'date': 'TxnDate', + 'txndate': 'TxnDate', + 'payee': 'EntityRef.value', + 'vendor': 'EntityRef.value', + 'amount': 'Line.Amount', + 'total': 'Line.Amount', + 'bankacct': 'AccountRef.value', + 'bankaccount': 'AccountRef.value', + 'account': 'AccountRef.value', + 'postingacct': 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'expenseaccount': 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'memo': 'PrivateNote', + 'periodmemo': 'PrivateNote', + 'checknumber': 'DocNumber', + 'checkno': 'DocNumber', + 'number': 'DocNumber', + }, + 'invoice': { + 'date': 'TxnDate', + 'txndate': 'TxnDate', + 'customer': 'CustomerRef.value', + 'customerref': 'CustomerRef.value', + 'amount': 'Line.Amount', + 'item': 'Line.SalesItemLineDetail.ItemRef.value', + 'description': 'Line.Description', + 'quantity': 'Line.SalesItemLineDetail.Qty', + 'qty': 'Line.SalesItemLineDetail.Qty', + 'rate': 'Line.SalesItemLineDetail.UnitPrice', + 'unitprice': 'Line.SalesItemLineDetail.UnitPrice', + 'duedate': 'DueDate', + 'docnumber': 'DocNumber', + 'invoicenumber': 'DocNumber', + }, + 'bill': { + 'date': 'TxnDate', + 'txndate': 'TxnDate', + 'vendor': 'VendorRef.value', + 'vendorref': 'VendorRef.value', + 'amount': 'Line.Amount', + 'account': 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'expenseaccount': 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'duedate': 'DueDate', + 'memo': 'PrivateNote', + 'docnumber': 'DocNumber', + }, + 'customer': { + 'name': 'DisplayName', + 'displayname': 'DisplayName', + 'companyname': 'CompanyName', + 'firstname': 'GivenName', + 'givenname': 'GivenName', + 'lastname': 'FamilyName', + 'familyname': 'FamilyName', + 'email': 'PrimaryEmailAddr.Address', + 'phone': 'PrimaryPhone.FreeFormNumber', + }, + 'vendor': { + 'name': 'DisplayName', + 'displayname': 'DisplayName', + 'companyname': 'CompanyName', + 'firstname': 'GivenName', + 'givenname': 'GivenName', + 'lastname': 'FamilyName', + 'familyname': 'FamilyName', + 'email': 'PrimaryEmailAddr.Address', + 'phone': 'PrimaryPhone.FreeFormNumber', + }, + 'account': { + 'name': 'Name', + 'accountname': 'Name', + 'type': 'AccountType', + 'accounttype': 'AccountType', + 'subtype': 'AccountSubType', + 'number': 'AcctNum', + 'accountnumber': 'AcctNum', + 'description': 'Description', + }, + } + + mappings = common_mappings.get(data_type, {}) + self.field_mappings = {} + + for col in excel_columns: + col_lower = col.lower().replace(' ', '').replace('_', '') + for pattern, qbo_field in mappings.items(): + if pattern in col_lower or col_lower in pattern: + self.field_mappings[col] = qbo_field + break + + self._update_mapping() + + mapped_count = len(self.field_mappings) + total_count = len(excel_columns) + messagebox.showinfo("Auto-Map Complete", f"Mapped {mapped_count} of {total_count} fields.") + + def _edit_mapping(self): + """Open mapping editor dialog.""" + if not hasattr(self, 'excel_columns') or not self.excel_columns: + messagebox.showwarning("No Data", "Please load a file first.") + return + + # Create mapping editor dialog + dialog = tk.Toplevel(self.root) + dialog.title("Edit Field Mapping") + dialog.geometry("700x500") + dialog.transient(self.root) + dialog.grab_set() + + ttk.Label(dialog, text="Map Excel columns to QuickBooks fields:", font=('Segoe UI', 10)).pack(pady=10) + + # Scrollable frame for mappings + canvas = tk.Canvas(dialog) + scrollbar = ttk.Scrollbar(dialog, orient=tk.VERTICAL, command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor=tk.NW) + canvas.configure(yscrollcommand=scrollbar.set) + + # Get QBO fields for this data type + qbo_fields = self._get_qbo_fields(self.data_type_var.get()) + + # Create mapping entries + excel_columns = self.excel_columns + + mapping_vars = {} + + for i, col in enumerate(excel_columns): + frame = ttk.Frame(scrollable_frame) + frame.pack(fill=tk.X, padx=10, pady=2) + + ttk.Label(frame, text=col, width=25, anchor=tk.W).pack(side=tk.LEFT) + ttk.Label(frame, text="→").pack(side=tk.LEFT, padx=10) + + var = tk.StringVar(value=self.field_mappings.get(col, '')) + combo = ttk.Combobox(frame, textvariable=var, values=[''] + qbo_fields, width=40) + combo.pack(side=tk.LEFT, fill=tk.X, expand=True) + + mapping_vars[col] = var + + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=10) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + def save_mappings(): + self.field_mappings = {col: var.get() for col, var in mapping_vars.items() if var.get()} + self._update_mapping() + dialog.destroy() + logger.info(f"[EDIT] Field mappings updated: {len(self.field_mappings)} fields mapped") + + btn_frame = ttk.Frame(dialog) + btn_frame.pack(fill=tk.X, pady=10) + + ttk.Button(btn_frame, text="Save", command=save_mappings).pack(side=tk.RIGHT, padx=10) + ttk.Button(btn_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT) + + def _get_qbo_fields(self, data_type: str) -> List[str]: + """Get available QBO fields for a data type from default templates.""" + # Try to get fields from default templates + default_templates = self.settings.get_default_templates() + template = default_templates.get(data_type) + + if template: + # Extract unique QBO fields from template + return list(set(m.qbo_field for m in template.mappings)) + + # Fallback to predefined fields with dot notation + fields = { + 'check': [ + 'TxnDate', + 'EntityRef.value', + 'AccountRef.value', + 'Line.Amount', + 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'DocNumber', + 'PrivateNote', + 'Line.Description', + ], + 'invoice': [ + 'TxnDate', + 'CustomerRef.value', + 'DueDate', + 'DocNumber', + 'Line.SalesItemLineDetail.ItemRef.value', + 'Line.Description', + 'Line.SalesItemLineDetail.Qty', + 'Line.SalesItemLineDetail.UnitPrice', + 'Line.Amount', + 'PrivateNote', + ], + 'bill': [ + 'TxnDate', + 'VendorRef.value', + 'DueDate', + 'DocNumber', + 'Line.AccountBasedExpenseLineDetail.AccountRef.value', + 'Line.Description', + 'Line.Amount', + 'PrivateNote', + ], + 'customer': [ + 'DisplayName', + 'CompanyName', + 'GivenName', + 'FamilyName', + 'PrimaryEmailAddr.Address', + 'PrimaryPhone.FreeFormNumber', + 'BillAddr.Line1', + 'BillAddr.City', + 'BillAddr.CountrySubDivisionCode', + 'BillAddr.PostalCode', + ], + 'vendor': [ + 'DisplayName', + 'CompanyName', + 'GivenName', + 'FamilyName', + 'PrimaryEmailAddr.Address', + 'PrimaryPhone.FreeFormNumber', + 'BillAddr.Line1', + 'BillAddr.City', + 'BillAddr.CountrySubDivisionCode', + 'BillAddr.PostalCode', + 'TaxIdentifier', + ], + 'account': [ + 'Name', + 'AccountType', + 'AccountSubType', + 'AcctNum', + 'Description', + 'CurrentBalance', + ], + } + return fields.get(data_type, []) + + def _save_template(self): + """Save current mapping as a template.""" + if not self.field_mappings: + messagebox.showwarning("No Mapping", "Please map fields first.") + return + + # Ask for template name + name = tk.simpledialog.askstring("Save Template", "Enter template name:") + if not name: + return + + # Import required classes + from src.config.settings import MappingTemplate, FieldMapping + + # Convert field_mappings dict to list of FieldMapping objects + mappings = [] + for excel_col, qbo_field in self.field_mappings.items(): + if qbo_field: + mappings.append(FieldMapping( + excel_column=excel_col, + qbo_field=qbo_field + )) + + template = MappingTemplate( + name=name, + data_type=self.data_type_var.get(), + mappings=mappings + ) + + self.settings.save_template(template) + messagebox.showinfo("Saved", f"Template '{name}' saved successfully.") + logger.info(f"[CREATE] Template saved: {name}") + self._refresh_templates() + + def _start_import(self): + """Start the import process.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + if not hasattr(self, 'excel_data') or not self.excel_data: + messagebox.showwarning("No Data", "Please load a file first.") + return + + if not self.field_mappings: + messagebox.showwarning("No Mapping", "Please map fields first.") + return + + # Confirm import + row_count = len(self.excel_data) + if not messagebox.askyesno("Confirm Import", f"Import {row_count} records to QuickBooks?"): + return + + # Disable buttons + self.import_btn.config(state=tk.DISABLED) + self.stop_btn.config(state=tk.NORMAL) + + # Clear log + self.log_viewer.clear() + self.log_viewer.log(f"Starting import of {row_count} records...") + + # Start import in background thread + self._import_cancelled = False + threading.Thread(target=self._run_import, daemon=True).start() + + def _run_import(self): + """Run the import in a background thread.""" + try: + data_type = self.data_type_var.get() + total = len(self.excel_data) + successful = 0 + failed = 0 + + # Cache for lookups to avoid repeated API calls + vendor_cache = {} + customer_cache = {} + account_cache = {} + + # Pre-load caches if needed + if self.connection_type == 'qbo' and self.qbo_client: + self.root.after(0, lambda: self.log_viewer.log("Loading reference data...", "INFO")) + try: + if data_type in ['check', 'bill']: + for v in self.qbo_client.get_vendors(): + vendor_cache[v.get('DisplayName', '').lower()] = v['Id'] + if data_type == 'invoice': + for c in self.qbo_client.get_customers(): + customer_cache[c.get('DisplayName', '').lower()] = c['Id'] + # Load accounts for check/bill + if data_type in ['check', 'bill']: + for a in self.qbo_client.get_accounts(): + account_cache[a.get('Name', '').lower()] = a['Id'] + except Exception as e: + self.root.after(0, lambda: self.log_viewer.log(f"Warning: Could not load all reference data: {e}", "WARNING")) + + for i, row in enumerate(self.excel_data): + if self._import_cancelled: + self.root.after(0, lambda: self.log_viewer.log("Import cancelled by user.", "WARNING")) + break + + try: + # Build QBO data from mapping + raw_data = {} + for excel_col, qbo_field in self.field_mappings.items(): + if qbo_field and excel_col in row: + raw_data[qbo_field] = row[excel_col] + + # Build proper QBO structure based on data type + qbo_data = self._build_qbo_structure( + data_type, raw_data, + vendor_cache, customer_cache, account_cache + ) + + # Create record in QuickBooks + if self.connection_type == 'qbo' and self.qbo_client: + if data_type == 'check': + self.qbo_client.create_check(qbo_data) + elif data_type == 'invoice': + self.qbo_client.create_invoice(qbo_data) + elif data_type == 'bill': + self.qbo_client.create_bill(qbo_data) + elif data_type == 'customer': + self.qbo_client.create_customer(qbo_data) + elif data_type == 'vendor': + self.qbo_client.create_vendor(qbo_data) + elif data_type == 'account': + self.qbo_client.create_account(qbo_data) + + successful += 1 + self.root.after(0, lambda i=i, s=successful: self._update_import_progress(i + 1, total, s)) + + except Exception as e: + failed += 1 + error_msg = str(e) + self.root.after(0, lambda i=i, e=error_msg: self.log_viewer.log(f"Row {i+1} failed: {e}", "ERROR")) + + # Complete + self.root.after(0, lambda: self._import_complete(successful, failed, total)) + + except Exception as e: + self.root.after(0, lambda: self._import_error(str(e))) + + def _build_qbo_structure( + self, + data_type: str, + raw_data: Dict[str, Any], + vendor_cache: Dict[str, str], + customer_cache: Dict[str, str], + account_cache: Dict[str, str] + ) -> Dict[str, Any]: + """Build proper QBO API structure from raw mapped data with dot-notation fields.""" + + def get_value(key: str) -> Any: + """Get value from raw_data handling dot notation keys.""" + # Direct match first + if key in raw_data: + return raw_data[key] + # Try with .value suffix + if key + '.value' in raw_data: + return raw_data[key + '.value'] + return None + + def format_date(value: Any) -> str: + """Format a date value to YYYY-MM-DD.""" + if value is None: + return None + if hasattr(value, 'strftime'): + return value.strftime('%Y-%m-%d') + # Handle string dates + str_val = str(value) + if 'T' in str_val: + return str_val.split('T')[0] + return str_val[:10] + + def resolve_vendor(name: str) -> str: + """Resolve vendor name to ID.""" + if not name: + return None + vendor_id = vendor_cache.get(str(name).strip().lower()) + if not vendor_id: + raise ValueError(f"Vendor not found: {name}") + return vendor_id + + def resolve_customer(name: str) -> str: + """Resolve customer name to ID.""" + if not name: + return None + customer_id = customer_cache.get(str(name).strip().lower()) + if not customer_id: + raise ValueError(f"Customer not found: {name}") + return customer_id + + def resolve_account(name: str) -> str: + """Resolve account name to ID.""" + if not name: + return None + account_id = account_cache.get(str(name).strip().lower()) + if not account_id: + raise ValueError(f"Account not found: {name}") + return account_id + + if data_type == 'check': + # Build check/purchase structure + check = {"PaymentType": "Check"} + + # Transaction date + txn_date = get_value('TxnDate') + if txn_date: + check['TxnDate'] = format_date(txn_date) + + # Entity reference (vendor) - handle EntityRef.value + vendor_name = get_value('EntityRef') + if vendor_name: + vendor_id = resolve_vendor(vendor_name) + check['EntityRef'] = {'value': vendor_id, 'name': str(vendor_name).strip()} + + # Bank account reference - handle AccountRef.value + account_name = get_value('AccountRef') + if account_name: + account_id = resolve_account(account_name) + check['AccountRef'] = {'value': account_id, 'name': str(account_name).strip()} + + # Line items + amount = get_value('Line.Amount') + if amount: + line = { + 'Amount': float(amount), + 'DetailType': 'AccountBasedExpenseLineDetail', + 'AccountBasedExpenseLineDetail': {} + } + + # Expense account + expense_account = get_value('Line.AccountBasedExpenseLineDetail.AccountRef') + if expense_account: + expense_id = resolve_account(expense_account) + line['AccountBasedExpenseLineDetail']['AccountRef'] = { + 'value': expense_id, + 'name': str(expense_account).strip() + } + elif check.get('AccountRef'): + # Use bank account as fallback + line['AccountBasedExpenseLineDetail']['AccountRef'] = check['AccountRef'] + + # Description + description = get_value('Line.Description') + if description: + line['Description'] = str(description) + + check['Line'] = [line] + + # Doc number + doc_number = get_value('DocNumber') + if doc_number: + check['DocNumber'] = str(doc_number) + + # Private note + private_note = get_value('PrivateNote') + if private_note: + check['PrivateNote'] = str(private_note) + + return check + + elif data_type == 'invoice': + invoice = {} + + # Transaction date + txn_date = get_value('TxnDate') + if txn_date: + invoice['TxnDate'] = format_date(txn_date) + + # Customer reference + customer_name = get_value('CustomerRef') + if customer_name: + customer_id = resolve_customer(customer_name) + invoice['CustomerRef'] = {'value': customer_id, 'name': str(customer_name).strip()} + + # Due date + due_date = get_value('DueDate') + if due_date: + invoice['DueDate'] = format_date(due_date) + + # Line items + amount = get_value('Line.Amount') + if amount: + line = { + 'Amount': float(amount), + 'DetailType': 'SalesItemLineDetail', + 'SalesItemLineDetail': {} + } + + # Item reference + item_ref = get_value('Line.SalesItemLineDetail.ItemRef') + if item_ref: + line['SalesItemLineDetail']['ItemRef'] = {'value': str(item_ref)} + + # Quantity + qty = get_value('Line.SalesItemLineDetail.Qty') + if qty: + line['SalesItemLineDetail']['Qty'] = float(qty) + + # Unit price + unit_price = get_value('Line.SalesItemLineDetail.UnitPrice') + if unit_price: + line['SalesItemLineDetail']['UnitPrice'] = float(unit_price) + + # Description + description = get_value('Line.Description') + if description: + line['Description'] = str(description) + + invoice['Line'] = [line] + + # Doc number + doc_number = get_value('DocNumber') + if doc_number: + invoice['DocNumber'] = str(doc_number) + + # Private note + private_note = get_value('PrivateNote') + if private_note: + invoice['PrivateNote'] = str(private_note) + + return invoice + + elif data_type == 'bill': + bill = {} + + # Transaction date + txn_date = get_value('TxnDate') + if txn_date: + bill['TxnDate'] = format_date(txn_date) + + # Vendor reference + vendor_name = get_value('VendorRef') + if vendor_name: + vendor_id = resolve_vendor(vendor_name) + bill['VendorRef'] = {'value': vendor_id, 'name': str(vendor_name).strip()} + + # Due date + due_date = get_value('DueDate') + if due_date: + bill['DueDate'] = format_date(due_date) + + # Line items + amount = get_value('Line.Amount') + if amount: + line = { + 'Amount': float(amount), + 'DetailType': 'AccountBasedExpenseLineDetail', + 'AccountBasedExpenseLineDetail': {} + } + + # Account reference + account_name = get_value('Line.AccountBasedExpenseLineDetail.AccountRef') + if account_name: + account_id = resolve_account(account_name) + line['AccountBasedExpenseLineDetail']['AccountRef'] = { + 'value': account_id, + 'name': str(account_name).strip() + } + + # Description + description = get_value('Line.Description') + if description: + line['Description'] = str(description) + + bill['Line'] = [line] + + # Doc number + doc_number = get_value('DocNumber') + if doc_number: + bill['DocNumber'] = str(doc_number) + + # Private note + private_note = get_value('PrivateNote') + if private_note: + bill['PrivateNote'] = str(private_note) + + return bill + + elif data_type == 'customer': + customer = {} + + display_name = get_value('DisplayName') + if display_name: + customer['DisplayName'] = str(display_name) + + company_name = get_value('CompanyName') + if company_name: + customer['CompanyName'] = str(company_name) + + given_name = get_value('GivenName') + if given_name: + customer['GivenName'] = str(given_name) + + family_name = get_value('FamilyName') + if family_name: + customer['FamilyName'] = str(family_name) + + email = get_value('PrimaryEmailAddr') or get_value('PrimaryEmailAddr.Address') + if email: + customer['PrimaryEmailAddr'] = {'Address': str(email)} + + phone = get_value('PrimaryPhone') or get_value('PrimaryPhone.FreeFormNumber') + if phone: + customer['PrimaryPhone'] = {'FreeFormNumber': str(phone)} + + # Address + addr_line1 = get_value('BillAddr.Line1') + if addr_line1: + customer['BillAddr'] = customer.get('BillAddr', {}) + customer['BillAddr']['Line1'] = str(addr_line1) + + addr_city = get_value('BillAddr.City') + if addr_city: + customer['BillAddr'] = customer.get('BillAddr', {}) + customer['BillAddr']['City'] = str(addr_city) + + addr_state = get_value('BillAddr.CountrySubDivisionCode') + if addr_state: + customer['BillAddr'] = customer.get('BillAddr', {}) + customer['BillAddr']['CountrySubDivisionCode'] = str(addr_state) + + addr_postal = get_value('BillAddr.PostalCode') + if addr_postal: + customer['BillAddr'] = customer.get('BillAddr', {}) + customer['BillAddr']['PostalCode'] = str(addr_postal) + + return customer + + elif data_type == 'vendor': + vendor = {} + + display_name = get_value('DisplayName') + if display_name: + vendor['DisplayName'] = str(display_name) + + company_name = get_value('CompanyName') + if company_name: + vendor['CompanyName'] = str(company_name) + + given_name = get_value('GivenName') + if given_name: + vendor['GivenName'] = str(given_name) + + family_name = get_value('FamilyName') + if family_name: + vendor['FamilyName'] = str(family_name) + + email = get_value('PrimaryEmailAddr') or get_value('PrimaryEmailAddr.Address') + if email: + vendor['PrimaryEmailAddr'] = {'Address': str(email)} + + phone = get_value('PrimaryPhone') or get_value('PrimaryPhone.FreeFormNumber') + if phone: + vendor['PrimaryPhone'] = {'FreeFormNumber': str(phone)} + + tax_id = get_value('TaxIdentifier') + if tax_id: + vendor['TaxIdentifier'] = str(tax_id) + + # Address + addr_line1 = get_value('BillAddr.Line1') + if addr_line1: + vendor['BillAddr'] = vendor.get('BillAddr', {}) + vendor['BillAddr']['Line1'] = str(addr_line1) + + addr_city = get_value('BillAddr.City') + if addr_city: + vendor['BillAddr'] = vendor.get('BillAddr', {}) + vendor['BillAddr']['City'] = str(addr_city) + + addr_state = get_value('BillAddr.CountrySubDivisionCode') + if addr_state: + vendor['BillAddr'] = vendor.get('BillAddr', {}) + vendor['BillAddr']['CountrySubDivisionCode'] = str(addr_state) + + addr_postal = get_value('BillAddr.PostalCode') + if addr_postal: + vendor['BillAddr'] = vendor.get('BillAddr', {}) + vendor['BillAddr']['PostalCode'] = str(addr_postal) + + return vendor + + elif data_type == 'account': + account = {} + + name = get_value('Name') + if name: + account['Name'] = str(name) + + account_type = get_value('AccountType') + if account_type: + account['AccountType'] = str(account_type) + + account_sub_type = get_value('AccountSubType') + if account_sub_type: + account['AccountSubType'] = str(account_sub_type) + + acct_num = get_value('AcctNum') + if acct_num: + account['AcctNum'] = str(acct_num) + + description = get_value('Description') + if description: + account['Description'] = str(description) + + return account + + else: + # Return raw data as-is for unknown types + return raw_data + + def _update_import_progress(self, current: int, total: int, successful: int): + """Update import progress.""" + self.progress_frame.set_progress( + current, total, + f"Importing... ({current}/{total})", + f"{successful} successful" + ) + + def _import_complete(self, successful: int, failed: int, total: int): + """Handle import completion.""" + self.import_btn.config(state=tk.NORMAL) + self.stop_btn.config(state=tk.DISABLED) + + self.progress_frame.set_progress( + total, total, + "Import Complete", + f"{successful} successful, {failed} failed" + ) + + self.log_viewer.log(f"Import complete: {successful} successful, {failed} failed", "SUCCESS" if failed == 0 else "WARNING") + + messagebox.showinfo("Import Complete", f"Imported {successful} of {total} records.\n{failed} failed.") + + def _import_error(self, error: str): + """Handle import error.""" + self.import_btn.config(state=tk.NORMAL) + self.stop_btn.config(state=tk.DISABLED) + + self.log_viewer.log(f"Import error: {error}", "ERROR") + messagebox.showerror("Import Error", f"Import failed: {error}") + + def _stop_import(self): + """Stop the current import.""" + self._import_cancelled = True + self.stop_btn.config(state=tk.DISABLED) + + # ========================================================================= + # Template Methods + # ========================================================================= + + def _refresh_templates(self): + """Refresh the templates list.""" + # Clear existing + for item in self.templates_tree.get_children(): + self.templates_tree.delete(item) + + # Load templates + templates = self.settings.get_templates() + + for template in templates: + # Handle both MappingTemplate objects and dict format + if hasattr(template, 'name'): + # MappingTemplate object + name = template.name + data_type = template.data_type + created = template.created_at[:10] if template.created_at else '' + mapping_count = len(template.mappings) + else: + # Dict format (from save_template with dict) + name = template.get('name', '') + data_type = template.get('data_type', '') + created = template.get('created', template.get('created_at', ''))[:10] + mapping_count = len(template.get('mappings', {})) + + self.templates_tree.insert('', tk.END, values=( + name, + data_type, + created, + f"{mapping_count} fields" + )) + + def _create_template(self): + """Create a new template.""" + messagebox.showinfo("Create Template", "Load a file and map fields first, then use 'Save as Template'.") + self.notebook.select(1) # Switch to Import tab + + def _edit_template(self): + """Edit selected template.""" + selection = self.templates_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a template to edit.") + return + + # Get template name + item = self.templates_tree.item(selection[0]) + template_name = item['values'][0] + + # Find template in list + templates = self.settings.get_templates() + template = None + for t in templates: + if hasattr(t, 'name') and t.name == template_name: + template = t + break + + if template: + # Convert MappingTemplate.mappings to dict format for field_mappings + self.field_mappings = {} + for mapping in template.mappings: + if hasattr(mapping, 'excel_column'): + self.field_mappings[mapping.excel_column] = mapping.qbo_field + else: + # Dict format fallback + self.field_mappings[mapping.get('excel_column', '')] = mapping.get('qbo_field', '') + + self.data_type_var.set(template.data_type if hasattr(template, 'data_type') else 'check') + self._update_mapping() + self.notebook.select(1) # Switch to Import tab + logger.info(f"[EDIT] Loaded template: {template_name}") + + def _duplicate_template(self): + """Duplicate selected template.""" + selection = self.templates_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a template to duplicate.") + return + + item = self.templates_tree.item(selection[0]) + template_name = item['values'][0] + + new_name = tk.simpledialog.askstring("Duplicate Template", "Enter new template name:") + if not new_name: + return + + # Find template in list + templates = self.settings.get_templates() + template = None + for t in templates: + if hasattr(t, 'name') and t.name == template_name: + template = t + break + + if template: + from src.config.settings import MappingTemplate + + # Create new template with copied mappings + new_template = MappingTemplate( + name=new_name, + data_type=template.data_type, + mappings=template.mappings.copy() if hasattr(template.mappings, 'copy') else list(template.mappings) + ) + + self.settings.save_template(new_template) + logger.info(f"[CREATE] Template duplicated: {template_name} -> {new_name}") + self._refresh_templates() + + def _delete_template(self): + """Delete selected template.""" + selection = self.templates_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a template to delete.") + return + + item = self.templates_tree.item(selection[0]) + template_name = item['values'][0] + + if messagebox.askyesno("Confirm Delete", f"Delete template '{template_name}'?"): + self.settings.delete_template(template_name) + self._refresh_templates() + + # ========================================================================= + # Other UI Methods + # ========================================================================= + + def _show_templates(self): + """Show templates tab.""" + self.notebook.select(3) + + def _show_settings(self): + """Show settings dialog.""" + dialog = tk.Toplevel(self.root) + dialog.title("Settings") + dialog.geometry("500x400") + dialog.transient(self.root) + dialog.grab_set() + + notebook = ttk.Notebook(dialog) + notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # Import settings tab + import_tab = ttk.Frame(notebook, padding=15) + notebook.add(import_tab, text="Import") + + import_settings = self.settings.get_import_settings() + + ttk.Label(import_tab, text="Date Format:").grid(row=0, column=0, sticky=tk.W, pady=5) + date_format_var = tk.StringVar(value=import_settings.date_format) + ttk.Entry(import_tab, textvariable=date_format_var, width=30).grid(row=0, column=1, pady=5) + + skip_empty_var = tk.BooleanVar(value=import_settings.skip_empty_rows) + ttk.Checkbutton(import_tab, text="Skip empty rows", variable=skip_empty_var).grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=5) + + validate_var = tk.BooleanVar(value=import_settings.validate_before_import) + ttk.Checkbutton(import_tab, text="Validate before import", variable=validate_var).grid(row=2, column=0, columnspan=2, sticky=tk.W, pady=5) + + stop_on_error_var = tk.BooleanVar(value=import_settings.stop_on_error) + ttk.Checkbutton(import_tab, text="Stop on first error", variable=stop_on_error_var).grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5) + + def save_settings(): + import_settings.date_format = date_format_var.get() + import_settings.skip_empty_rows = skip_empty_var.get() + import_settings.validate_before_import = validate_var.get() + import_settings.stop_on_error = stop_on_error_var.get() + self.settings.save_import_settings(import_settings) + dialog.destroy() + messagebox.showinfo("Saved", "Settings saved successfully.") + + ttk.Button(dialog, text="Save", command=save_settings).pack(pady=10) + + def _show_logs(self): + """Show log viewer dialog.""" + dialog = tk.Toplevel(self.root) + dialog.title("Application Logs") + dialog.geometry("800x500") + + # Read log file + log_file = PROJECT_ROOT / 'desktop_app.log' + + 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()) + else: + text.insert(tk.END, "No log file found.") + + text.config(state=tk.DISABLED) + + 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(): + 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) + + 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="Close", command=dialog.destroy).pack(side=tk.RIGHT) + + def _show_help(self): + """Show help documentation.""" + help_text = """ +QBO Excel Sync - Desktop Application +===================================== + +This application allows you to import Excel data into QuickBooks Online +or QuickBooks Desktop. + +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" + +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 + +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 more information, visit the documentation website. + """ + + dialog = tk.Toplevel(self.root) + dialog.title("Help") + dialog.geometry("600x500") + + text = scrolledtext.ScrolledText(dialog, wrap=tk.WORD, font=('Segoe UI', 10)) + text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + text.insert(tk.END, help_text) + text.config(state=tk.DISABLED) + + ttk.Button(dialog, text="Close", command=dialog.destroy).pack(pady=10) + + def _show_about(self): + """Show about dialog.""" + messagebox.showinfo( + "About", + f"{APP_NAME}\nVersion {APP_VERSION}\n\n" + "Import Excel data to QuickBooks Online or Desktop.\n\n" + "© 2024 QBO Excel Sync" + ) + + def _on_close(self): + """Handle window close event.""" + if messagebox.askyesno("Exit", "Are you sure you want to exit?"): + logger.info("Application closing") + self.root.destroy() + + def run(self): + """Run the application.""" + self.root.mainloop() + + +# ============================================================================= +# Entry Point +# ============================================================================= + +def main(): + """Main entry point.""" + # Add simpledialog for askstring + import tkinter.simpledialog + tk.simpledialog = tkinter.simpledialog + + app = QBOExcelSyncApp() + app.run() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirement_desktop.txt b/requirement_desktop.txt new file mode 100644 index 0000000..6fcfad0 --- /dev/null +++ b/requirement_desktop.txt @@ -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 \ No newline at end of file