Files
QB-Import-from-Excel/desktop_app.py
T
2026-02-17 12:23:22 -05:00

2662 lines
104 KiB
Python

#!/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('<Control-o>', 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('<<ComboboxSelected>>', 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 = """
<html>
<head><title>Authorization Successful</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: #22c55e;">✓ Authorization Successful!</h1>
<p>You can close this window and return to the application.</p>
<script>window.close();</script>
</body>
</html>
"""
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"""
<html>
<head><title>Authorization Failed</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1 style="color: #ef4444;">✗ Authorization Failed</h1>
<p>{callback_data['error']}</p>
<p>Please close this window and try again.</p>
</body>
</html>
"""
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 = """<?xml version="1.0" encoding="utf-8"?>
<?qbxml version="13.0"?>
<QBXML>
<QBXMLMsgsRq onError="stopOnError">
<CompanyQueryRq></CompanyQueryRq>
</QBXMLMsgsRq>
</QBXML>"""
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(
"<Configure>",
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()