Update app callback server

This commit is contained in:
2026-02-17 17:31:32 -05:00
parent 2b4a485819
commit 34d73a6d2c
3 changed files with 826 additions and 20 deletions
+368 -20
View File
@@ -25,7 +25,7 @@ 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.config.settings import Settings, ImportSettings, QBOCredentials, ConnectionProfile
from src.api.qbo_client import QBOClient
from src.core.excel_parser import ExcelParser, ParseResult
from src.core.import_processor import ImportProcessor, ImportResult
@@ -644,6 +644,31 @@ class QBOExcelSyncApp:
self.qbo_frame = ttk.LabelFrame(tab, text="QuickBooks Online Connection", padding=15)
self.qbo_frame.pack(fill=tk.X, pady=10)
# Profile selection row
profile_frame = ttk.Frame(self.qbo_frame)
profile_frame.pack(fill=tk.X, pady=(0, 15))
ttk.Label(profile_frame, text="Connection Profile:", font=('Segoe UI', 10, 'bold')).pack(side=tk.LEFT)
self.profile_var = tk.StringVar(value="")
self.profile_combo = ttk.Combobox(
profile_frame,
textvariable=self.profile_var,
values=[],
width=30,
state='readonly'
)
self.profile_combo.pack(side=tk.LEFT, padx=(10, 5))
self.profile_combo.bind('<<ComboboxSelected>>', self._on_profile_change)
ttk.Button(profile_frame, text="Load", command=self._load_selected_profile, width=8).pack(side=tk.LEFT, padx=2)
ttk.Button(profile_frame, text="Save", command=self._save_profile, width=8).pack(side=tk.LEFT, padx=2)
ttk.Button(profile_frame, text="Save As...", command=self._save_profile_as, width=10).pack(side=tk.LEFT, padx=2)
ttk.Button(profile_frame, text="Delete", command=self._delete_profile, width=8).pack(side=tk.LEFT, padx=2)
# Separator
ttk.Separator(self.qbo_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# API Credentials
creds_frame = ttk.Frame(self.qbo_frame)
creds_frame.pack(fill=tk.X, pady=(0, 10))
@@ -670,8 +695,7 @@ class QBOExcelSyncApp:
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)
ttk.Button(qbo_buttons, text="Connect to QuickBooks", command=self._start_oauth, width=25).pack(side=tk.LEFT)
# Advanced options in a second row
qbo_buttons2 = ttk.Frame(self.qbo_frame)
@@ -683,7 +707,7 @@ class QBOExcelSyncApp:
# QBD Connection Frame
self.qbd_frame = ttk.LabelFrame(tab, text="QuickBooks Desktop Connection", padding=15)
self.qbd_frame.pack(fill=tk.X, pady=10)
# Initially hidden - will be shown when QBD is selected
if HAS_QBD:
qbd_info = ttk.Label(
@@ -713,25 +737,25 @@ class QBOExcelSyncApp:
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.status_frame = ttk.LabelFrame(tab, text="Connection Status", padding=15)
self.status_frame.pack(fill=tk.X, pady=20)
self.conn_status_label = ttk.Label(
status_frame,
self.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,
self.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 = ttk.Frame(self.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)
@@ -740,7 +764,8 @@ class QBOExcelSyncApp:
disconnect_btn = ttk.Button(conn_actions, text="Disconnect", command=self._disconnect, width=12)
disconnect_btn.pack(side=tk.LEFT, padx=10)
# Load saved credentials
# Load profiles and saved credentials
self._refresh_profiles()
self._load_saved_credentials()
def _create_templates_tab(self):
@@ -865,10 +890,11 @@ class QBOExcelSyncApp:
tree_container = ttk.Frame(results_frame)
tree_container.pack(fill=tk.BOTH, expand=True)
self.lookup_columns = ['Id', 'Name', 'Type', 'SubType', 'Balance', 'Active']
self.lookup_columns = ['RawId', 'Id', 'Name', 'Type', 'SubType', 'Balance', 'Active']
self.lookup_tree = ttk.Treeview(tree_container, columns=self.lookup_columns, show='headings', height=15)
# Default column configuration
self.lookup_tree.heading('RawId', text='Raw ID')
self.lookup_tree.heading('Id', text='ID')
self.lookup_tree.heading('Name', text='Name')
self.lookup_tree.heading('Type', text='Type')
@@ -876,6 +902,7 @@ class QBOExcelSyncApp:
self.lookup_tree.heading('Balance', text='Balance')
self.lookup_tree.heading('Active', text='Active')
self.lookup_tree.column('RawId', width=150, minwidth=100)
self.lookup_tree.column('Id', width=60, minwidth=50)
self.lookup_tree.column('Name', width=200, minwidth=100)
self.lookup_tree.column('Type', width=120, minwidth=80)
@@ -1108,6 +1135,7 @@ class QBOExcelSyncApp:
if entity_type == 'account':
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('Name', ''),
entity.get('AccountType', ''),
@@ -1117,6 +1145,7 @@ class QBOExcelSyncApp:
)
elif entity_type in ['customer', 'vendor']:
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('DisplayName', ''),
entity.get('CompanyName', ''),
@@ -1127,6 +1156,7 @@ class QBOExcelSyncApp:
)
elif entity_type == 'item':
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('Name', ''),
entity.get('Type', ''),
@@ -1136,6 +1166,7 @@ class QBOExcelSyncApp:
)
elif entity_type in ['class', 'department']:
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('Name', ''),
entity.get('FullyQualifiedName', ''),
@@ -1143,6 +1174,7 @@ class QBOExcelSyncApp:
)
elif entity_type == 'term':
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('Name', ''),
entity.get('DueDays', ''),
@@ -1150,6 +1182,7 @@ class QBOExcelSyncApp:
)
elif entity_type == 'paymentmethod':
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('Name', ''),
entity.get('Type', ''),
@@ -1157,6 +1190,7 @@ class QBOExcelSyncApp:
)
elif entity_type == 'employee':
return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''),
entity.get('DisplayName', ''),
entity.get('GivenName', ''),
@@ -1164,7 +1198,7 @@ class QBOExcelSyncApp:
format_val(entity.get('Active')),
)
else:
return (entity.get('Id', ''), entity.get('Name', ''))
return (entity.get('RawId', entity.get('Id', '')), entity.get('Id', ''), entity.get('Name', ''))
def _copy_lookup_selection(self):
"""Copy selected lookup row to clipboard."""
@@ -1174,8 +1208,8 @@ class QBOExcelSyncApp:
return
values = self.lookup_tree.item(selection[0])['values']
# Format as ID: Name
text = f"{values[0]}: {values[1]}"
# Format as ID: Name (ID is now at index 1, Name at index 2)
text = f"{values[1]}: {values[2]}"
self.root.clipboard_clear()
self.root.clipboard_append(text)
@@ -1259,7 +1293,8 @@ class QBOExcelSyncApp:
creds = QBOCredentials(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri
redirect_uri=redirect_uri,
environment=environment
)
self.settings.qbo_environment = environment
@@ -1278,8 +1313,103 @@ class QBOExcelSyncApp:
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)
# Check if using localhost or remote callback
if 'localhost' in redirect_uri or '127.0.0.1' in redirect_uri:
# Local callback - use automatic capture
self._start_oauth_with_callback(client_id, client_secret, redirect_uri)
else:
# Remote callback - open browser and prompt for manual code entry
self._start_oauth_remote_callback(client_id, client_secret, redirect_uri)
def _start_oauth_remote_callback(self, client_id: str, client_secret: str, redirect_uri: str):
"""Start OAuth flow with remote callback server (for production)."""
from urllib.parse import urlencode
# Generate OAuth URL
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',
'prompt': 'consent',
}
auth_url = f"{auth_base}?{urlencode(params)}"
# Open browser
webbrowser.open(auth_url)
# Show dialog for manual code entry
dialog = tk.Toplevel(self.root)
dialog.title("Complete Authorization")
dialog.geometry("550x350")
dialog.transient(self.root)
dialog.grab_set()
# Center the dialog
dialog.update_idletasks()
x = (dialog.winfo_screenwidth() - 550) // 2
y = (dialog.winfo_screenheight() - 350) // 2
dialog.geometry(f"550x350+{x}+{y}")
ttk.Label(
dialog,
text="Complete the authorization in your browser.\n\n"
"After authorizing, you'll be redirected to your callback server.\n"
"Copy the Authorization Code and Realm ID from that page:",
wraplength=500,
justify=tk.CENTER,
font=('Segoe UI', 10)
).pack(padx=20, pady=20)
# Code entry
code_frame = ttk.Frame(dialog)
code_frame.pack(fill=tk.X, padx=30, 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 ID entry
realm_frame = ttk.Frame(dialog)
realm_frame.pack(fill=tk.X, padx=30, pady=5)
ttk.Label(realm_frame, text="Realm ID (Company 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)
def reopen_browser():
webbrowser.open(auth_url)
# Buttons
btn_frame = ttk.Frame(dialog)
btn_frame.pack(pady=25)
ttk.Button(btn_frame, text="Connect", command=submit, width=15).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Re-open Browser", command=reopen_browser, width=15).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Cancel", command=dialog.destroy, width=12).pack(side=tk.LEFT, padx=5)
# Help text
ttk.Label(
dialog,
text="Tip: The code expires in 5 minutes. Click 'Re-open Browser' if needed.",
foreground='gray',
font=('Segoe UI', 9)
).pack(pady=(0, 10))
logger.info(f"[OAUTH] Started remote callback OAuth flow, redirect_uri: {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."""
@@ -1426,7 +1556,10 @@ class QBOExcelSyncApp:
'response_type': 'code',
'scope': 'com.intuit.quickbooks.accounting',
'redirect_uri': redirect_uri,
'state': 'desktop_app'
'state': 'desktop_app',
# Force user to select company file every time
# This ensures switching profiles connects to the correct company
'prompt': 'consent',
}
auth_url = f"{auth_base}?{urlencode(params)}"
@@ -1536,11 +1669,13 @@ class QBOExcelSyncApp:
if response.status_code == 200:
token_data = response.json()
# Save credentials
# Save credentials with environment setting
environment = self.env_var.get()
creds = QBOCredentials(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
environment=environment,
access_token=token_data.get('access_token'),
refresh_token=token_data.get('refresh_token'),
realm_id=realm_id,
@@ -1559,6 +1694,24 @@ class QBOExcelSyncApp:
self._update_connection_ui()
# Update current profile with tokens if one is selected
profile_name = self.profile_var.get() if hasattr(self, 'profile_var') else None
if profile_name:
profile = ConnectionProfile(
name=profile_name,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
environment=self.env_var.get(),
access_token=token_data.get('access_token'),
refresh_token=token_data.get('refresh_token'),
realm_id=realm_id,
company_name=self.company_name,
token_expiry=datetime.now().isoformat(),
)
self.settings.save_profile(profile)
logger.info(f"[PROFILE] Updated profile '{profile_name}' with new tokens")
messagebox.showinfo("Connected", f"Successfully connected to {self.company_name}")
logger.info(f"Connected to QBO: {self.company_name}")
else:
@@ -1603,6 +1756,7 @@ class QBOExcelSyncApp:
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(),
access_token=access_token,
refresh_token=refresh_token,
realm_id=realm_id,
@@ -1788,9 +1942,13 @@ except Exception as e:
"""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)
# Show QBO frame, hide QBD frame
self.qbd_frame.pack_forget()
self.qbo_frame.pack(fill=tk.X, pady=10, before=self.status_frame)
else:
# Show QBD frame, hide QBO frame
self.qbo_frame.pack_forget()
self.qbd_frame.pack(fill=tk.X, pady=10, before=self.status_frame)
def _load_saved_credentials(self):
"""Load saved credentials into the form."""
@@ -1806,6 +1964,188 @@ except Exception as e:
self.redirect_uri_entry.delete(0, tk.END)
self.redirect_uri_entry.insert(0, creds.redirect_uri)
# =========================================================================
# Profile Management Methods
# =========================================================================
def _refresh_profiles(self):
"""Refresh the profile dropdown list."""
profiles = self.settings.get_profiles()
profile_names = [p.name for p in profiles]
self.profile_combo['values'] = profile_names
# Select active profile if exists
active_profile = self.settings.get_active_profile_name()
if active_profile and active_profile in profile_names:
self.profile_var.set(active_profile)
elif profile_names:
self.profile_var.set(profile_names[0])
else:
self.profile_var.set("")
def _on_profile_change(self, event=None):
"""Handle profile selection change."""
# Just update the selection, don't auto-load
pass
def _load_selected_profile(self):
"""Load the selected profile into the form."""
profile_name = self.profile_var.get()
if not profile_name:
messagebox.showwarning("No Profile", "Please select a profile to load.")
return
profile = self.settings.get_profile(profile_name)
if not profile:
messagebox.showerror("Error", f"Profile '{profile_name}' not found.")
return
# Load profile data into form
self.client_id_entry.delete(0, tk.END)
self.client_id_entry.insert(0, profile.client_id or '')
self.client_secret_entry.delete(0, tk.END)
self.client_secret_entry.insert(0, profile.client_secret or '')
self.redirect_uri_entry.delete(0, tk.END)
self.redirect_uri_entry.insert(0, profile.redirect_uri or 'http://localhost:9000/oauth/callback')
self.env_var.set(profile.environment or 'sandbox')
# Set as active profile
self.settings.set_active_profile(profile_name)
# If profile has tokens, try to connect
if profile.access_token and profile.realm_id:
try:
creds = QBOCredentials(
client_id=profile.client_id,
client_secret=profile.client_secret,
redirect_uri=profile.redirect_uri,
environment=profile.environment or 'sandbox',
access_token=profile.access_token,
refresh_token=profile.refresh_token,
realm_id=profile.realm_id,
token_expiry=profile.token_expiry
)
self.settings.save_credentials(creds)
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', profile.company_name or 'Unknown')
self._update_connection_ui()
logger.info(f"[PROFILE] Connected using profile: {profile_name}")
messagebox.showinfo("Profile Loaded", f"Connected to {self.company_name}")
else:
messagebox.showinfo("Profile Loaded", f"Profile '{profile_name}' loaded.\nClick 'Connect to QuickBooks' to authenticate.")
except Exception as e:
logger.warning(f"Could not auto-connect with profile: {e}")
messagebox.showinfo("Profile Loaded", f"Profile '{profile_name}' loaded.\nTokens expired. Please reconnect.")
else:
messagebox.showinfo("Profile Loaded", f"Profile '{profile_name}' loaded.\nClick 'Connect to QuickBooks' to authenticate.")
logger.info(f"[PROFILE] Loaded profile: {profile_name}")
def _save_profile(self):
"""Save current settings to the selected profile."""
profile_name = self.profile_var.get()
if not profile_name:
# No profile selected, prompt for new name
self._save_profile_as()
return
# Confirm overwrite
if not messagebox.askyesno("Save Profile", f"Overwrite profile '{profile_name}'?"):
return
self._save_profile_with_name(profile_name)
def _save_profile_as(self):
"""Save current settings as a new profile."""
# Ask for profile name
name = tk.simpledialog.askstring(
"Save Profile As",
"Enter a name for this profile:",
parent=self.root
)
if not name:
return
name = name.strip()
if not name:
messagebox.showwarning("Invalid Name", "Please enter a valid profile name.")
return
# Check if profile already exists
existing = self.settings.get_profile(name)
if existing:
if not messagebox.askyesno("Profile Exists", f"Profile '{name}' already exists. Overwrite?"):
return
self._save_profile_with_name(name)
# Update dropdown
self._refresh_profiles()
self.profile_var.set(name)
def _save_profile_with_name(self, name: str):
"""Save profile with the given name."""
# Get current credentials if connected
creds = self.settings.get_credentials()
profile = ConnectionProfile(
name=name,
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(),
access_token=creds.access_token if creds else None,
refresh_token=creds.refresh_token if creds else None,
realm_id=creds.realm_id if creds else None,
company_name=self.company_name if self.is_connected else None,
token_expiry=creds.token_expiry if creds else None,
)
self.settings.save_profile(profile)
self.settings.set_active_profile(name)
messagebox.showinfo("Profile Saved", f"Profile '{name}' saved successfully.")
logger.info(f"[PROFILE] Saved profile: {name}")
# Refresh the dropdown
self._refresh_profiles()
def _delete_profile(self):
"""Delete the selected profile."""
profile_name = self.profile_var.get()
if not profile_name:
messagebox.showwarning("No Profile", "Please select a profile to delete.")
return
if not messagebox.askyesno("Delete Profile", f"Are you sure you want to delete profile '{profile_name}'?"):
return
self.settings.delete_profile(profile_name)
# Clear active profile if it was the deleted one
if self.settings.get_active_profile_name() == profile_name:
self.settings.set_active_profile(None)
# Refresh dropdown
self._refresh_profiles()
# Clear form if needed
if not self.profile_var.get():
self.client_id_entry.delete(0, tk.END)
self.client_secret_entry.delete(0, tk.END)
messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.")
logger.info(f"[PROFILE] Deleted profile: {profile_name}")
# =========================================================================
# Import Methods
# =========================================================================
@@ -3484,6 +3824,7 @@ except Exception as e:
if entity_type == 'account':
balance = e.get('Balance') or e.get('TotalBalance') or '0'
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''),
'AccountType': e.get('AccountType', ''),
@@ -3494,6 +3835,7 @@ except Exception as e:
elif entity_type in ['customer', 'vendor']:
balance = e.get('Balance') or e.get('TotalBalance') or '0'
entity = {
'RawId': raw_id,
'Id': clean_id,
'DisplayName': e.get('Name') or e.get('FullName', ''),
'CompanyName': e.get('CompanyName', ''),
@@ -3505,6 +3847,7 @@ except Exception as e:
elif entity_type == 'item':
price = e.get('UnitPrice') or e.get('SalesPrice') or '0'
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''),
'Type': 'Service',
@@ -3514,6 +3857,7 @@ except Exception as e:
}
elif entity_type == 'employee':
entity = {
'RawId': raw_id,
'Id': clean_id,
'DisplayName': e.get('Name') or e.get('FullName', ''),
'GivenName': e.get('FirstName', ''),
@@ -3522,6 +3866,7 @@ except Exception as e:
}
elif entity_type == 'class':
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name', ''),
'FullyQualifiedName': e.get('FullName', ''),
@@ -3529,6 +3874,7 @@ except Exception as e:
}
elif entity_type == 'paymentmethod':
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name', ''),
'Type': e.get('PaymentMethodType', ''),
@@ -3536,6 +3882,7 @@ except Exception as e:
}
elif entity_type == 'term':
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name', ''),
'DueDays': e.get('DueDays') or e.get('StdDueDays', ''),
@@ -3543,6 +3890,7 @@ except Exception as e:
}
else:
entity = {
'RawId': raw_id,
'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''),
'Active': str(e.get('IsActive', 'true')).lower() == 'true',