Update app callback server
This commit is contained in:
+368
-20
@@ -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
|
||||||
self._start_oauth_with_callback(client_id, client_secret, redirect_uri)
|
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):
|
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',
|
||||||
|
|||||||
@@ -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 © {{ 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)
|
||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user