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
+367 -19
View File
@@ -25,7 +25,7 @@ PROJECT_ROOT = Path(__file__).parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
# Import project modules # 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.api.qbo_client import QBOClient
from src.core.excel_parser import ExcelParser, ParseResult from src.core.excel_parser import ExcelParser, ParseResult
from src.core.import_processor import ImportProcessor, ImportResult 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 = ttk.LabelFrame(tab, text="QuickBooks Online Connection", padding=15)
self.qbo_frame.pack(fill=tk.X, pady=10) 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 # API Credentials
creds_frame = ttk.Frame(self.qbo_frame) creds_frame = ttk.Frame(self.qbo_frame)
creds_frame.pack(fill=tk.X, pady=(0, 10)) creds_frame.pack(fill=tk.X, pady=(0, 10))
@@ -670,8 +695,7 @@ class QBOExcelSyncApp:
qbo_buttons = ttk.Frame(self.qbo_frame) qbo_buttons = ttk.Frame(self.qbo_frame)
qbo_buttons.pack(fill=tk.X, pady=10) 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)
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 # Advanced options in a second row
qbo_buttons2 = ttk.Frame(self.qbo_frame) qbo_buttons2 = ttk.Frame(self.qbo_frame)
@@ -683,7 +707,7 @@ class QBOExcelSyncApp:
# QBD Connection Frame # QBD Connection Frame
self.qbd_frame = ttk.LabelFrame(tab, text="QuickBooks Desktop Connection", padding=15) 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: if HAS_QBD:
qbd_info = ttk.Label( qbd_info = ttk.Label(
@@ -713,25 +737,25 @@ class QBOExcelSyncApp:
self.qbd_connect_btn.pack(side=tk.LEFT) self.qbd_connect_btn.pack(side=tk.LEFT)
# Connection status # Connection status
status_frame = ttk.LabelFrame(tab, text="Connection Status", padding=15) self.status_frame = ttk.LabelFrame(tab, text="Connection Status", padding=15)
status_frame.pack(fill=tk.X, pady=20) self.status_frame.pack(fill=tk.X, pady=20)
self.conn_status_label = ttk.Label( self.conn_status_label = ttk.Label(
status_frame, self.status_frame,
text="● Not Connected", text="● Not Connected",
font=('Segoe UI', 11) font=('Segoe UI', 11)
) )
self.conn_status_label.pack(anchor=tk.W) self.conn_status_label.pack(anchor=tk.W)
self.conn_company_label = ttk.Label( self.conn_company_label = ttk.Label(
status_frame, self.status_frame,
text="", text="",
font=('Segoe UI', 10), font=('Segoe UI', 10),
foreground='gray' foreground='gray'
) )
self.conn_company_label.pack(anchor=tk.W, pady=(5, 0)) 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)) 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 = 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 = ttk.Button(conn_actions, text="Disconnect", command=self._disconnect, width=12)
disconnect_btn.pack(side=tk.LEFT, padx=10) disconnect_btn.pack(side=tk.LEFT, padx=10)
# Load saved credentials # Load profiles and saved credentials
self._refresh_profiles()
self._load_saved_credentials() self._load_saved_credentials()
def _create_templates_tab(self): def _create_templates_tab(self):
@@ -865,10 +890,11 @@ class QBOExcelSyncApp:
tree_container = ttk.Frame(results_frame) tree_container = ttk.Frame(results_frame)
tree_container.pack(fill=tk.BOTH, expand=True) 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) self.lookup_tree = ttk.Treeview(tree_container, columns=self.lookup_columns, show='headings', height=15)
# Default column configuration # Default column configuration
self.lookup_tree.heading('RawId', text='Raw ID')
self.lookup_tree.heading('Id', text='ID') self.lookup_tree.heading('Id', text='ID')
self.lookup_tree.heading('Name', text='Name') self.lookup_tree.heading('Name', text='Name')
self.lookup_tree.heading('Type', text='Type') self.lookup_tree.heading('Type', text='Type')
@@ -876,6 +902,7 @@ class QBOExcelSyncApp:
self.lookup_tree.heading('Balance', text='Balance') self.lookup_tree.heading('Balance', text='Balance')
self.lookup_tree.heading('Active', text='Active') 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('Id', width=60, minwidth=50)
self.lookup_tree.column('Name', width=200, minwidth=100) self.lookup_tree.column('Name', width=200, minwidth=100)
self.lookup_tree.column('Type', width=120, minwidth=80) self.lookup_tree.column('Type', width=120, minwidth=80)
@@ -1108,6 +1135,7 @@ class QBOExcelSyncApp:
if entity_type == 'account': if entity_type == 'account':
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('Name', ''), entity.get('Name', ''),
entity.get('AccountType', ''), entity.get('AccountType', ''),
@@ -1117,6 +1145,7 @@ class QBOExcelSyncApp:
) )
elif entity_type in ['customer', 'vendor']: elif entity_type in ['customer', 'vendor']:
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('DisplayName', ''), entity.get('DisplayName', ''),
entity.get('CompanyName', ''), entity.get('CompanyName', ''),
@@ -1127,6 +1156,7 @@ class QBOExcelSyncApp:
) )
elif entity_type == 'item': elif entity_type == 'item':
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('Name', ''), entity.get('Name', ''),
entity.get('Type', ''), entity.get('Type', ''),
@@ -1136,6 +1166,7 @@ class QBOExcelSyncApp:
) )
elif entity_type in ['class', 'department']: elif entity_type in ['class', 'department']:
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('Name', ''), entity.get('Name', ''),
entity.get('FullyQualifiedName', ''), entity.get('FullyQualifiedName', ''),
@@ -1143,6 +1174,7 @@ class QBOExcelSyncApp:
) )
elif entity_type == 'term': elif entity_type == 'term':
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('Name', ''), entity.get('Name', ''),
entity.get('DueDays', ''), entity.get('DueDays', ''),
@@ -1150,6 +1182,7 @@ class QBOExcelSyncApp:
) )
elif entity_type == 'paymentmethod': elif entity_type == 'paymentmethod':
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('Name', ''), entity.get('Name', ''),
entity.get('Type', ''), entity.get('Type', ''),
@@ -1157,6 +1190,7 @@ class QBOExcelSyncApp:
) )
elif entity_type == 'employee': elif entity_type == 'employee':
return ( return (
entity.get('RawId', entity.get('Id', '')),
entity.get('Id', ''), entity.get('Id', ''),
entity.get('DisplayName', ''), entity.get('DisplayName', ''),
entity.get('GivenName', ''), entity.get('GivenName', ''),
@@ -1164,7 +1198,7 @@ class QBOExcelSyncApp:
format_val(entity.get('Active')), format_val(entity.get('Active')),
) )
else: 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): def _copy_lookup_selection(self):
"""Copy selected lookup row to clipboard.""" """Copy selected lookup row to clipboard."""
@@ -1174,8 +1208,8 @@ class QBOExcelSyncApp:
return return
values = self.lookup_tree.item(selection[0])['values'] values = self.lookup_tree.item(selection[0])['values']
# Format as ID: Name # Format as ID: Name (ID is now at index 1, Name at index 2)
text = f"{values[0]}: {values[1]}" text = f"{values[1]}: {values[2]}"
self.root.clipboard_clear() self.root.clipboard_clear()
self.root.clipboard_append(text) self.root.clipboard_append(text)
@@ -1259,7 +1293,8 @@ class QBOExcelSyncApp:
creds = QBOCredentials( creds = QBOCredentials(
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
redirect_uri=redirect_uri redirect_uri=redirect_uri,
environment=environment
) )
self.settings.qbo_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.") messagebox.showwarning("Missing Information", "Please enter Client ID and Client Secret first.")
return return
# Start OAuth with automatic callback capture # 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) 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): 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.""" """Start OAuth flow with automatic callback capture using a local server."""
@@ -1426,7 +1556,10 @@ class QBOExcelSyncApp:
'response_type': 'code', 'response_type': 'code',
'scope': 'com.intuit.quickbooks.accounting', 'scope': 'com.intuit.quickbooks.accounting',
'redirect_uri': redirect_uri, '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)}" auth_url = f"{auth_base}?{urlencode(params)}"
@@ -1536,11 +1669,13 @@ class QBOExcelSyncApp:
if response.status_code == 200: if response.status_code == 200:
token_data = response.json() token_data = response.json()
# Save credentials # Save credentials with environment setting
environment = self.env_var.get()
creds = QBOCredentials( creds = QBOCredentials(
client_id=client_id, client_id=client_id,
client_secret=client_secret, client_secret=client_secret,
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
environment=environment,
access_token=token_data.get('access_token'), access_token=token_data.get('access_token'),
refresh_token=token_data.get('refresh_token'), refresh_token=token_data.get('refresh_token'),
realm_id=realm_id, realm_id=realm_id,
@@ -1559,6 +1694,24 @@ class QBOExcelSyncApp:
self._update_connection_ui() 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}") messagebox.showinfo("Connected", f"Successfully connected to {self.company_name}")
logger.info(f"Connected to QBO: {self.company_name}") logger.info(f"Connected to QBO: {self.company_name}")
else: else:
@@ -1603,6 +1756,7 @@ class QBOExcelSyncApp:
client_id=self.client_id_entry.get().strip(), client_id=self.client_id_entry.get().strip(),
client_secret=self.client_secret_entry.get().strip(), client_secret=self.client_secret_entry.get().strip(),
redirect_uri=self.redirect_uri_entry.get().strip(), redirect_uri=self.redirect_uri_entry.get().strip(),
environment=self.env_var.get(),
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
realm_id=realm_id, realm_id=realm_id,
@@ -1788,9 +1942,13 @@ except Exception as e:
"""Handle connection type change.""" """Handle connection type change."""
conn_type = self.conn_type_var.get() conn_type = self.conn_type_var.get()
if conn_type == 'qbo': 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: else:
# Show QBD frame, hide QBO frame
self.qbo_frame.pack_forget() self.qbo_frame.pack_forget()
self.qbd_frame.pack(fill=tk.X, pady=10, before=self.status_frame)
def _load_saved_credentials(self): def _load_saved_credentials(self):
"""Load saved credentials into the form.""" """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.delete(0, tk.END)
self.redirect_uri_entry.insert(0, creds.redirect_uri) 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 # Import Methods
# ========================================================================= # =========================================================================
@@ -3484,6 +3824,7 @@ except Exception as e:
if entity_type == 'account': if entity_type == 'account':
balance = e.get('Balance') or e.get('TotalBalance') or '0' balance = e.get('Balance') or e.get('TotalBalance') or '0'
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''), 'Name': e.get('Name') or e.get('FullName', ''),
'AccountType': e.get('AccountType', ''), 'AccountType': e.get('AccountType', ''),
@@ -3494,6 +3835,7 @@ except Exception as e:
elif entity_type in ['customer', 'vendor']: elif entity_type in ['customer', 'vendor']:
balance = e.get('Balance') or e.get('TotalBalance') or '0' balance = e.get('Balance') or e.get('TotalBalance') or '0'
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'DisplayName': e.get('Name') or e.get('FullName', ''), 'DisplayName': e.get('Name') or e.get('FullName', ''),
'CompanyName': e.get('CompanyName', ''), 'CompanyName': e.get('CompanyName', ''),
@@ -3505,6 +3847,7 @@ except Exception as e:
elif entity_type == 'item': elif entity_type == 'item':
price = e.get('UnitPrice') or e.get('SalesPrice') or '0' price = e.get('UnitPrice') or e.get('SalesPrice') or '0'
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''), 'Name': e.get('Name') or e.get('FullName', ''),
'Type': 'Service', 'Type': 'Service',
@@ -3514,6 +3857,7 @@ except Exception as e:
} }
elif entity_type == 'employee': elif entity_type == 'employee':
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'DisplayName': e.get('Name') or e.get('FullName', ''), 'DisplayName': e.get('Name') or e.get('FullName', ''),
'GivenName': e.get('FirstName', ''), 'GivenName': e.get('FirstName', ''),
@@ -3522,6 +3866,7 @@ except Exception as e:
} }
elif entity_type == 'class': elif entity_type == 'class':
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name', ''), 'Name': e.get('Name', ''),
'FullyQualifiedName': e.get('FullName', ''), 'FullyQualifiedName': e.get('FullName', ''),
@@ -3529,6 +3874,7 @@ except Exception as e:
} }
elif entity_type == 'paymentmethod': elif entity_type == 'paymentmethod':
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name', ''), 'Name': e.get('Name', ''),
'Type': e.get('PaymentMethodType', ''), 'Type': e.get('PaymentMethodType', ''),
@@ -3536,6 +3882,7 @@ except Exception as e:
} }
elif entity_type == 'term': elif entity_type == 'term':
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name', ''), 'Name': e.get('Name', ''),
'DueDays': e.get('DueDays') or e.get('StdDueDays', ''), 'DueDays': e.get('DueDays') or e.get('StdDueDays', ''),
@@ -3543,6 +3890,7 @@ except Exception as e:
} }
else: else:
entity = { entity = {
'RawId': raw_id,
'Id': clean_id, 'Id': clean_id,
'Name': e.get('Name') or e.get('FullName', ''), 'Name': e.get('Name') or e.get('FullName', ''),
'Active': str(e.get('IsActive', 'true')).lower() == 'true', 'Active': str(e.get('IsActive', 'true')).lower() == 'true',
+368
View File
@@ -0,0 +1,368 @@
"""
QBO Excel Sync - OAuth Callback Server
This is a simple Flask server that handles OAuth callbacks from QuickBooks.
Host this on your own server (e.g., https://yourcompany.com/oauth/callback)
Usage:
1. Deploy this to your server
2. Set up SSL (required for QuickBooks production)
3. Register the callback URL in your QuickBooks app settings
4. Update the desktop app's redirect_uri to match
Environment Variables:
- PORT: Server port (default: 5000)
- SECRET_KEY: Flask secret key for sessions
"""
import os
import logging
from datetime import datetime
from flask import Flask, request, render_template_string, jsonify
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
# HTML template for displaying the authorization code
CALLBACK_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QBO Excel Sync - Authorization</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 600px;
width: 100%;
text-align: center;
}
.success-icon {
width: 80px;
height: 80px;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.success-icon svg {
width: 40px;
height: 40px;
fill: white;
}
.error-icon {
width: 80px;
height: 80px;
background: #ef4444;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.error-icon svg {
width: 40px;
height: 40px;
fill: white;
}
h1 {
color: #1f2937;
font-size: 28px;
margin-bottom: 16px;
}
.subtitle {
color: #6b7280;
font-size: 16px;
margin-bottom: 32px;
}
.code-section {
background: #f3f4f6;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.code-label {
color: #374151;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.code-box {
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #1f2937;
word-break: break-all;
margin-bottom: 16px;
max-height: 120px;
overflow-y: auto;
}
.copy-btn {
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.copy-btn:hover {
background: #2563eb;
transform: translateY(-1px);
}
.copy-btn:active {
transform: translateY(0);
}
.copy-btn.copied {
background: #10b981;
}
.realm-info {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
text-align: left;
}
.realm-info strong {
color: #92400e;
}
.instructions {
text-align: left;
color: #4b5563;
font-size: 14px;
line-height: 1.6;
}
.instructions ol {
margin-left: 20px;
margin-top: 12px;
}
.instructions li {
margin-bottom: 8px;
}
.error-message {
background: #fef2f2;
border: 1px solid #ef4444;
border-radius: 8px;
padding: 16px;
color: #991b1b;
text-align: left;
}
.footer {
margin-top: 32px;
color: #9ca3af;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
{% if error %}
<div class="error-icon">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
</div>
<h1>Authorization Failed</h1>
<div class="error-message">
<strong>Error:</strong> {{ error }}<br>
{% if error_description %}
<strong>Details:</strong> {{ error_description }}
{% endif %}
</div>
{% else %}
<div class="success-icon">
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
</div>
<h1>Authorization Successful!</h1>
<p class="subtitle">Copy the information below and paste it into the QBO Excel Sync app.</p>
<div class="realm-info">
<strong>Company ID (Realm ID):</strong> {{ realm_id }}
</div>
<div class="code-section">
<div class="code-label">Authorization Code</div>
<div class="code-box" id="authCode">{{ code }}</div>
<button class="copy-btn" onclick="copyCode()">
<span id="btnText">Copy Authorization Code</span>
</button>
</div>
<div class="instructions">
<strong>Next Steps:</strong>
<ol>
<li>Go back to the <strong>QBO Excel Sync</strong> desktop application</li>
<li>Click <strong>"Enter Code Manually"</strong> button</li>
<li>Paste the <strong>Authorization Code</strong> and <strong>Company ID</strong></li>
<li>Click <strong>Connect</strong></li>
</ol>
</div>
{% endif %}
<div class="footer">
QBO Excel Sync &copy; {{ year }} | You can close this window after copying the code.
</div>
</div>
<script>
function copyCode() {
const code = document.getElementById('authCode').textContent;
navigator.clipboard.writeText(code).then(() => {
const btn = document.querySelector('.copy-btn');
const btnText = document.getElementById('btnText');
btn.classList.add('copied');
btnText.textContent = '✓ Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btnText.textContent = 'Copy Authorization Code';
}, 2000);
});
}
</script>
</body>
</html>
"""
@app.route('/')
def index():
"""Health check endpoint."""
return jsonify({
'status': 'ok',
'service': 'QBO Excel Sync OAuth Callback Server',
'timestamp': datetime.now().isoformat()
})
@app.route('/oauth/callback')
def oauth_callback():
"""
Handle OAuth callback from QuickBooks.
QuickBooks redirects here with:
- code: Authorization code (on success)
- realmId: Company ID
- state: State parameter we sent
- error: Error code (on failure)
- error_description: Error details (on failure)
"""
# Get parameters
code = request.args.get('code')
realm_id = request.args.get('realmId')
state = request.args.get('state')
error = request.args.get('error')
error_description = request.args.get('error_description')
# Log the callback
logger.info(f"OAuth callback received - realm_id: {realm_id}, state: {state}, error: {error}")
if error:
logger.error(f"OAuth error: {error} - {error_description}")
return render_template_string(
CALLBACK_TEMPLATE,
error=error,
error_description=error_description,
year=datetime.now().year
)
if not code:
logger.error("No authorization code received")
return render_template_string(
CALLBACK_TEMPLATE,
error="No authorization code received",
error_description="The authorization server did not return a code.",
year=datetime.now().year
)
# Success - display the code to the user
logger.info(f"Authorization successful for realm {realm_id}")
return render_template_string(
CALLBACK_TEMPLATE,
code=code,
realm_id=realm_id,
state=state,
error=None,
year=datetime.now().year
)
@app.route('/oauth/callback/json')
def oauth_callback_json():
"""
JSON endpoint for programmatic access.
Can be used if you want to implement automatic code relay.
"""
code = request.args.get('code')
realm_id = request.args.get('realmId')
state = request.args.get('state')
error = request.args.get('error')
error_description = request.args.get('error_description')
if error:
return jsonify({
'success': False,
'error': error,
'error_description': error_description
}), 400
return jsonify({
'success': True,
'code': code,
'realm_id': realm_id,
'state': state
})
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
debug = os.environ.get('DEBUG', 'false').lower() == 'true'
print(f"""
QBO Excel Sync - OAuth Callback Server
Server running on port {port}
Callback URL: http://localhost:{port}/oauth/callback
For production, deploy with HTTPS (required by QuickBooks)
Example: https://yourcompany.com/oauth/callback
""")
app.run(host='0.0.0.0', port=port, debug=debug)
+90
View File
@@ -29,6 +29,23 @@ class QBOCredentials:
token_expiry: Optional[str] = None token_expiry: Optional[str] = None
@dataclass
class ConnectionProfile:
"""Connection profile for QuickBooks Online."""
name: str
client_id: str = ""
client_secret: str = ""
redirect_uri: str = "http://localhost:9000/oauth/callback"
environment: str = "sandbox" # 'sandbox' or 'production'
access_token: Optional[str] = None
refresh_token: Optional[str] = None
realm_id: Optional[str] = None
company_name: Optional[str] = None
token_expiry: Optional[str] = None
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
@dataclass @dataclass
class FieldMapping: class FieldMapping:
"""Single field mapping configuration.""" """Single field mapping configuration."""
@@ -247,6 +264,79 @@ class Settings:
self.credentials_file.unlink() self.credentials_file.unlink()
logger.info("Credentials cleared") logger.info("Credentials cleared")
# Connection Profile management
def get_profiles(self) -> List[ConnectionProfile]:
"""Get all connection profiles."""
profiles = []
profiles_file = self.config_dir / "profiles.json"
if profiles_file.exists():
try:
with open(profiles_file, 'r') as f:
profiles_data = json.load(f)
for p in profiles_data:
profiles.append(ConnectionProfile(**p))
except Exception as e:
logger.warning(f"Failed to load profiles: {e}")
return sorted(profiles, key=lambda p: p.name)
def get_profile(self, name: str) -> Optional[ConnectionProfile]:
"""Get a specific profile by name."""
profiles = self.get_profiles()
for p in profiles:
if p.name == name:
return p
return None
def save_profile(self, profile: ConnectionProfile):
"""Save a connection profile."""
profile.updated_at = datetime.now().isoformat()
profiles = self.get_profiles()
# Update existing or add new
found = False
for i, p in enumerate(profiles):
if p.name == profile.name:
profiles[i] = profile
found = True
break
if not found:
profiles.append(profile)
# Save to file
profiles_file = self.config_dir / "profiles.json"
try:
with open(profiles_file, 'w') as f:
json.dump([asdict(p) for p in profiles], f, indent=2)
logger.info(f"Profile saved: {profile.name}")
except Exception as e:
logger.error(f"Failed to save profile: {e}")
def delete_profile(self, name: str):
"""Delete a connection profile."""
profiles = self.get_profiles()
profiles = [p for p in profiles if p.name != name]
profiles_file = self.config_dir / "profiles.json"
try:
with open(profiles_file, 'w') as f:
json.dump([asdict(p) for p in profiles], f, indent=2)
logger.info(f"Profile deleted: {name}")
except Exception as e:
logger.error(f"Failed to delete profile: {e}")
def get_active_profile_name(self) -> Optional[str]:
"""Get the name of the currently active profile."""
return self._config.get("active_profile", None)
def set_active_profile(self, name: Optional[str]):
"""Set the active profile name."""
self._config["active_profile"] = name
self.save()
logger.info(f"Active profile set to: {name}")
# Template management # Template management
def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]: def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]:
"""Get all mapping templates, optionally filtered by data type.""" """Get all mapping templates, optionally filtered by data type."""