diff --git a/desktop_app.py b/desktop_app.py index 54325fe..d133004 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -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('<>', 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', diff --git a/qbo_aouth_callback_server.py b/qbo_aouth_callback_server.py new file mode 100644 index 0000000..2fdeaff --- /dev/null +++ b/qbo_aouth_callback_server.py @@ -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 = """ + + + + + + QBO Excel Sync - Authorization + + + +
+ {% if error %} +
+ +
+

Authorization Failed

+
+ Error: {{ error }}
+ {% if error_description %} + Details: {{ error_description }} + {% endif %} +
+ {% else %} +
+ +
+

Authorization Successful!

+

Copy the information below and paste it into the QBO Excel Sync app.

+ +
+ Company ID (Realm ID): {{ realm_id }} +
+ +
+
Authorization Code
+
{{ code }}
+ +
+ +
+ Next Steps: +
    +
  1. Go back to the QBO Excel Sync desktop application
  2. +
  3. Click "Enter Code Manually" button
  4. +
  5. Paste the Authorization Code and Company ID
  6. +
  7. Click Connect
  8. +
+
+ {% endif %} + + +
+ + + + +""" + + +@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) \ No newline at end of file diff --git a/src/config/settings.py b/src/config/settings.py index f39e342..5b1a37b 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -29,6 +29,23 @@ class QBOCredentials: 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 class FieldMapping: """Single field mapping configuration.""" @@ -247,6 +264,79 @@ class Settings: self.credentials_file.unlink() 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 def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]: """Get all mapping templates, optionally filtered by data type."""