Initial Codes
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
#My folders
|
||||
upload/
|
||||
.flask_session/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
@@ -1 +1,244 @@
|
||||
# QB-Import-from-Excel
|
||||
# QBO Excel Sync Web Application
|
||||
|
||||
A Flask-based web application for importing Excel data to QuickBooks Online.
|
||||
|
||||
## Features
|
||||
|
||||
- **OAuth 2.0 Authentication**: Secure connection to QuickBooks Online
|
||||
- **Excel File Upload**: Support for .xlsx and .xls files
|
||||
- **Field Mapping**: Visual mapping of Excel columns to QBO fields
|
||||
- **Data Validation**: Pre-import validation with error reporting
|
||||
- **Batch Import**: Efficient batch processing with duplicate detection
|
||||
- **Custom Templates**: Save and reuse field mapping configurations
|
||||
|
||||
## Supported Data Types
|
||||
|
||||
1. **Checks** - Bank checks with payee, amount, memo
|
||||
2. **Invoices** - Customer invoices with line items
|
||||
3. **Bills** - Vendor bills with expenses
|
||||
4. **Customers** - Customer master data
|
||||
5. **Vendors** - Vendor master data
|
||||
6. **Chart of Accounts** - Account setup
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qbo_excel_sync_web/
|
||||
├── app.py # Main Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ └── qbo_client.py # QuickBooks API client
|
||||
│ ├── config/
|
||||
│ │ └── settings.py # Configuration management
|
||||
│ └── core/
|
||||
│ ├── excel_parser.py # Excel file processing
|
||||
│ └── import_processor.py # Import logic
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Application styles
|
||||
│ └── js/
|
||||
│ └── app.js # Shared JavaScript utilities
|
||||
├── templates/
|
||||
│ ├── base.html # Base template with header/nav
|
||||
│ ├── index.html # Dashboard
|
||||
│ ├── connection.html # OAuth connection management
|
||||
│ ├── import.html # File upload and import
|
||||
│ ├── templates.html # Template management
|
||||
│ ├── settings.html # Application settings
|
||||
│ ├── oauth_result.html # OAuth callback result
|
||||
│ ├── 404.html # Not found error page
|
||||
│ └── 500.html # Server error page
|
||||
├── data/ # Config storage (auto-created)
|
||||
│ ├── config.json # Application settings
|
||||
│ ├── .credentials # OAuth tokens (secured)
|
||||
│ └── templates/ # Custom mapping templates
|
||||
└── uploads/ # Uploaded files (auto-created)
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone or copy the project files**
|
||||
|
||||
2. **Install Python dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Set environment variables (optional):**
|
||||
```bash
|
||||
export SECRET_KEY="your-secure-secret-key"
|
||||
export FLASK_DEBUG=false # Set to false for production
|
||||
export PORT=5000 # Optional, default is 5000
|
||||
```
|
||||
|
||||
4. **Run the application:**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
5. **Open in browser:**
|
||||
Navigate to `http://localhost:5000`
|
||||
|
||||
## QuickBooks Online Setup
|
||||
|
||||
### Creating an App
|
||||
|
||||
1. Go to [Intuit Developer Portal](https://developer.intuit.com/)
|
||||
2. Sign in or create a developer account
|
||||
3. Click "My Apps" → "Create an app"
|
||||
4. Select "QuickBooks Online and Payments"
|
||||
5. Note your Client ID and Client Secret
|
||||
|
||||
### Configuring OAuth
|
||||
|
||||
1. In your app settings, add Redirect URI:
|
||||
- Development: `http://localhost:5000/oauth/callback`
|
||||
- Production: `https://yourdomain.com/oauth/callback`
|
||||
|
||||
2. Select required scopes:
|
||||
- `com.intuit.quickbooks.accounting`
|
||||
|
||||
### Using the Sandbox
|
||||
|
||||
1. In Developer Portal, go to "Dashboard" → "Sandbox"
|
||||
2. Create a sandbox company for testing
|
||||
3. Use "Sandbox" environment in the application
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### 1. Connect to QuickBooks
|
||||
|
||||
1. Navigate to **Connection** page
|
||||
2. Enter your Client ID and Client Secret
|
||||
3. Select Environment (Sandbox or Production)
|
||||
4. Click **Start OAuth Flow**
|
||||
5. Authorize in the QuickBooks popup
|
||||
6. Verify connection with **Test Connection**
|
||||
|
||||
### 2. Import Data
|
||||
|
||||
1. Navigate to **Import** page
|
||||
2. Upload your Excel file (drag & drop or browse)
|
||||
3. Select the sheet to import
|
||||
4. Choose the data type (Check, Invoice, etc.)
|
||||
5. Configure field mappings in the Mapping tab
|
||||
6. Click **Validate** to check for errors
|
||||
7. Click **Import to QuickBooks** to execute
|
||||
|
||||
### 3. Create Templates
|
||||
|
||||
1. Navigate to **Templates** page
|
||||
2. Click **+ New** to create a template
|
||||
3. Select data type and load default fields
|
||||
4. Configure field mappings
|
||||
5. Save for reuse in future imports
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Connection
|
||||
- `POST /api/credentials` - Save OAuth credentials
|
||||
- `POST /api/oauth/start` - Start OAuth flow
|
||||
- `GET /oauth/callback` - OAuth redirect handler
|
||||
- `POST /api/oauth/manual` - Manual token entry
|
||||
- `POST /api/disconnect` - Revoke tokens
|
||||
- `GET /api/connection/test` - Test connection
|
||||
|
||||
### File Operations
|
||||
- `POST /api/upload` - Upload Excel file
|
||||
- `GET /api/file/sheets` - Get sheet names
|
||||
- `GET /api/file/columns?sheet=X` - Get column names
|
||||
- `GET /api/file/preview?sheet=X&max_rows=20` - Preview data
|
||||
|
||||
### Import
|
||||
- `POST /api/validate` - Validate mapped data
|
||||
- `POST /api/import` - Execute import
|
||||
|
||||
### Templates
|
||||
- `GET /api/templates` - List all templates
|
||||
- `GET /api/templates/default/{type}` - Get default template
|
||||
- `POST /api/templates` - Save template
|
||||
- `DELETE /api/templates/{name}` - Delete template
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get settings
|
||||
- `POST /api/settings` - Update settings
|
||||
- `POST /api/settings/clear-credentials` - Clear credentials
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Import Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| skip_duplicates | true | Skip records matching existing QBO data |
|
||||
| duplicate_check_fields | ["DocNumber"] | Fields to check for duplicates |
|
||||
| batch_size | 50 | Records per batch (1-200) |
|
||||
| validate_before_import | true | Validate all data before import |
|
||||
| create_missing_references | false | Auto-create missing customers/vendors |
|
||||
| date_format | %Y-%m-%d | Expected date format |
|
||||
| decimal_separator | . | Decimal separator for numbers |
|
||||
| thousand_separator | , | Thousands separator |
|
||||
|
||||
## Field Mapping Reference
|
||||
|
||||
### Check Fields
|
||||
| Excel Column | QBO Field | Transform |
|
||||
|--------------|-----------|-----------|
|
||||
| Payee | EntityRef.value | text |
|
||||
| Bank Account | AccountRef.value | text |
|
||||
| Date | TxnDate | date |
|
||||
| Amount | Line.Amount | currency |
|
||||
| Check Number | DocNumber | text |
|
||||
| Memo | PrivateNote | text |
|
||||
|
||||
### Invoice Fields
|
||||
| Excel Column | QBO Field | Transform |
|
||||
|--------------|-----------|-----------|
|
||||
| Customer | CustomerRef.value | text |
|
||||
| Date | TxnDate | date |
|
||||
| Due Date | DueDate | date |
|
||||
| Invoice Number | DocNumber | text |
|
||||
| Item | Line.SalesItemLineDetail.ItemRef.value | text |
|
||||
| Amount | Line.Amount | currency |
|
||||
|
||||
## Logging
|
||||
|
||||
Application logs are written to:
|
||||
- Console output
|
||||
- `app.log` file in application directory
|
||||
|
||||
Log format: `timestamp - module - level - message`
|
||||
|
||||
User actions (create, edit, delete) are automatically logged with:
|
||||
- Timestamp
|
||||
- User IP address
|
||||
- Action type
|
||||
- Details
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Credentials Storage**: OAuth credentials are stored in a separate `.credentials` file with restricted permissions
|
||||
2. **Session Security**: Set a strong `SECRET_KEY` in production
|
||||
3. **File Uploads**: Validated for extension and size (16MB max)
|
||||
4. **HTTPS**: Use HTTPS in production for OAuth callbacks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Not Connected" Error
|
||||
- Verify credentials are saved correctly
|
||||
- Check if tokens have expired (re-authenticate)
|
||||
- Ensure redirect URI matches exactly
|
||||
|
||||
### "Entity Not Found" During Import
|
||||
- Enable "Auto-create missing references" in settings
|
||||
- Verify customer/vendor names match exactly
|
||||
- Check account names/numbers
|
||||
|
||||
### "Duplicate Detection" Issues
|
||||
- Adjust duplicate_check_fields in settings
|
||||
- Clear existing records or disable skip_duplicates
|
||||
|
||||
## License
|
||||
|
||||
Proprietary - Internal Use Only
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"client_id": "ABiruRoBjzGAKMgF31yvpRb2j2tPo08dZsVHzOqQL55vHP8dnz", "client_secret": "ZHQEJRTI7CE4Unm6mL8iloFaL9od8F1Njz6tjXv1", "redirect_uri": "http://localhost:5000/callback", "environment": "sandbox", "access_token": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwieC5vcmciOiJIMCJ9..hCiI9XjYKIsVzFSNvcD3rQ.mvxnviuuonHZpDFehIsBS3ZUdeGMusjdGbwU2oM7pC2sSQXe7hd4xMb6MzLDdmUhnhOc_JtXmvRstS3Ic1qpHFqDWxHh1QbrlbW4OBU91hYnPc6-CC9lYb7VhNQO_A8CLGjUyplG3Db19A1GRQzXit1kJId_HaV2mY_JTP6bI4f1yhKD4eNnRKVO0LQwg8aMXwcmEJXH6YGzrukIrrl-sjiXzJRqJyU71kj3qoedSVvlcCz0_YCkbJK5a8hln73RTiEqTg3Prsq0UJLPyoGBWFqgB-k4kSOFZgsPXrx6035h6fiPedzcJgGlPWPqN_7O4M3jlVa9wGnCpSNWJ42JPRF6XCoilRDGaNYljJsDMz54ZHTWIvaRBO8hcLxYoQs2Oz3ZFn8ZmpRdlQZEMckFGNSwp1nDIfq_PEsBesmYpx1wVPxjBDxIcO0Nq5wf3MOxKm24ZjUMG0hVfNSruJErgH9v2dMWIKp-gcHWzXlDBOE.iJ5qRVp1kDF3YlKky-J7Ww", "refresh_token": "RT1-89-H0-17797182624khhi6gb525o89u1m5sf", "realm_id": "9341456380310090", "token_expiry": "2026-02-13T10:06:01.672169"}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"import_settings": {
|
||||
"skip_duplicates": true,
|
||||
"duplicate_check_fields": [
|
||||
"DocNumber"
|
||||
],
|
||||
"batch_size": 100,
|
||||
"validate_before_import": true,
|
||||
"create_missing_references": false,
|
||||
"date_format": "%m/%d/%Y",
|
||||
"decimal_separator": ".",
|
||||
"thousand_separator": ","
|
||||
},
|
||||
"recent_files": [],
|
||||
"last_template": null,
|
||||
"qbo_environment": "sandbox"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "LT Payroll",
|
||||
"data_type": "check",
|
||||
"mappings": [
|
||||
{
|
||||
"excel_column": "Payee",
|
||||
"qbo_field": "EntityRef.value",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"excel_column": "Bank Acct",
|
||||
"qbo_field": "AccountRef.value",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"excel_column": "Date",
|
||||
"qbo_field": "TxnDate",
|
||||
"transform": "date",
|
||||
"default_value": null,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"excel_column": "Amount",
|
||||
"qbo_field": "Line.Amount",
|
||||
"transform": "currency",
|
||||
"default_value": null,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"excel_column": "Number",
|
||||
"qbo_field": "DocNumber",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"excel_column": "Posting Acct",
|
||||
"qbo_field": "Line.AccountBasedExpenseLineDetail.AccountRef.value",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"excel_column": "Period Memo",
|
||||
"qbo_field": "PrivateNote",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"excel_column": "Period Memo",
|
||||
"qbo_field": "Line.Description",
|
||||
"transform": null,
|
||||
"default_value": null,
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"created_at": "2026-02-12T12:39:07.304264",
|
||||
"updated_at": "2026-02-12T12:39:07.304281"
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QBO Account ID Lookup Script
|
||||
============================
|
||||
This script connects to QuickBooks Online and retrieves all accounts
|
||||
with their IDs, names, types, and other details.
|
||||
|
||||
Usage:
|
||||
python get_account_ids.py [--type TYPE] [--search NAME] [--export FILE]
|
||||
|
||||
Examples:
|
||||
python get_account_ids.py # List all accounts
|
||||
python get_account_ids.py --type Bank # List only Bank accounts
|
||||
python get_account_ids.py --search Checking # Search by name
|
||||
python get_account_ids.py --export accounts.csv # Export to CSV
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path to import from src
|
||||
script_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from src.config.settings import Settings
|
||||
from src.api.qbo_client import QBOClient
|
||||
|
||||
|
||||
def get_accounts(client, account_type=None, search_term=None):
|
||||
"""
|
||||
Fetch accounts from QBO with optional filtering.
|
||||
|
||||
Args:
|
||||
client: QBOClient instance
|
||||
account_type: Filter by account type (Bank, Expense, Income, etc.)
|
||||
search_term: Search accounts by name
|
||||
|
||||
Returns:
|
||||
List of account dictionaries
|
||||
"""
|
||||
# Build query
|
||||
query = "SELECT * FROM Account"
|
||||
conditions = []
|
||||
|
||||
if account_type:
|
||||
conditions.append(f"AccountType = '{account_type}'")
|
||||
|
||||
if search_term:
|
||||
conditions.append(f"Name LIKE '%{search_term}%'")
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDERBY Name"
|
||||
|
||||
# Execute query
|
||||
response = client._make_request('GET', 'query', params={'query': query})
|
||||
|
||||
if response and 'QueryResponse' in response:
|
||||
return response['QueryResponse'].get('Account', [])
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def format_account_table(accounts):
|
||||
"""Format accounts as a readable table."""
|
||||
if not accounts:
|
||||
return "No accounts found."
|
||||
|
||||
# Define columns
|
||||
headers = ['ID', 'Name', 'Type', 'SubType', 'AcctNum', 'Balance', 'Active']
|
||||
|
||||
# Calculate column widths
|
||||
widths = {
|
||||
'ID': max(len('ID'), max(len(str(a.get('Id', ''))) for a in accounts)),
|
||||
'Name': max(len('Name'), min(40, max(len(str(a.get('Name', ''))) for a in accounts))),
|
||||
'Type': max(len('Type'), max(len(str(a.get('AccountType', ''))) for a in accounts)),
|
||||
'SubType': max(len('SubType'), max(len(str(a.get('AccountSubType', ''))) for a in accounts)),
|
||||
'AcctNum': max(len('AcctNum'), max(len(str(a.get('AcctNum', ''))) for a in accounts)),
|
||||
'Balance': max(len('Balance'), 12),
|
||||
'Active': max(len('Active'), 6),
|
||||
}
|
||||
|
||||
# Build header
|
||||
header_row = " | ".join(h.ljust(widths[h]) for h in headers)
|
||||
separator = "-+-".join("-" * widths[h] for h in headers)
|
||||
|
||||
lines = [header_row, separator]
|
||||
|
||||
# Build data rows
|
||||
for account in accounts:
|
||||
name = str(account.get('Name', ''))[:40]
|
||||
balance = account.get('CurrentBalance', 0)
|
||||
balance_str = f"${balance:,.2f}" if balance else "-"
|
||||
|
||||
row = [
|
||||
str(account.get('Id', '')).ljust(widths['ID']),
|
||||
name.ljust(widths['Name']),
|
||||
str(account.get('AccountType', '')).ljust(widths['Type']),
|
||||
str(account.get('AccountSubType', '')).ljust(widths['SubType']),
|
||||
str(account.get('AcctNum', '')).ljust(widths['AcctNum']),
|
||||
balance_str.rjust(widths['Balance']),
|
||||
('Yes' if account.get('Active', True) else 'No').ljust(widths['Active']),
|
||||
]
|
||||
lines.append(" | ".join(row))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def export_to_csv(accounts, filepath):
|
||||
"""Export accounts to CSV file."""
|
||||
import csv
|
||||
|
||||
headers = ['Id', 'Name', 'FullyQualifiedName', 'AccountType', 'AccountSubType',
|
||||
'AcctNum', 'CurrentBalance', 'Active', 'Classification', 'Description']
|
||||
|
||||
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=headers, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for account in accounts:
|
||||
writer.writerow(account)
|
||||
|
||||
print(f"Exported {len(accounts)} accounts to {filepath}")
|
||||
|
||||
|
||||
def export_to_json(accounts, filepath):
|
||||
"""Export accounts to JSON file."""
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(accounts, f, indent=2)
|
||||
|
||||
print(f"Exported {len(accounts)} accounts to {filepath}")
|
||||
|
||||
|
||||
def print_account_types(accounts):
|
||||
"""Print summary of account types."""
|
||||
type_counts = {}
|
||||
for account in accounts:
|
||||
acct_type = account.get('AccountType', 'Unknown')
|
||||
type_counts[acct_type] = type_counts.get(acct_type, 0) + 1
|
||||
|
||||
print("\nAccount Types Summary:")
|
||||
print("-" * 30)
|
||||
for acct_type, count in sorted(type_counts.items()):
|
||||
print(f" {acct_type}: {count}")
|
||||
print("-" * 30)
|
||||
print(f" Total: {len(accounts)}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Fetch Account IDs from QuickBooks Online',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Account Types:
|
||||
Bank - Bank accounts (checking, savings)
|
||||
Accounts Receivable - Customer receivables
|
||||
Other Current Asset - Other current assets
|
||||
Fixed Asset - Property, equipment, etc.
|
||||
Other Asset - Other long-term assets
|
||||
Accounts Payable - Vendor payables
|
||||
Credit Card - Credit card accounts
|
||||
Other Current Liability - Other current liabilities
|
||||
Long Term Liability - Long-term debt
|
||||
Equity - Owner's equity accounts
|
||||
Income - Revenue accounts
|
||||
Cost of Goods Sold - COGS accounts
|
||||
Expense - Expense accounts
|
||||
Other Income - Other income
|
||||
Other Expense - Other expenses
|
||||
|
||||
Examples:
|
||||
%(prog)s --type Bank
|
||||
%(prog)s --type Expense --search Office
|
||||
%(prog)s --export accounts.csv
|
||||
%(prog)s --type "Accounts Payable"
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--type', '-t', dest='account_type',
|
||||
help='Filter by account type (e.g., Bank, Expense, Income)')
|
||||
parser.add_argument('--search', '-s', dest='search_term',
|
||||
help='Search accounts by name')
|
||||
parser.add_argument('--export', '-e', dest='export_file',
|
||||
help='Export to file (CSV or JSON based on extension)')
|
||||
parser.add_argument('--summary', action='store_true',
|
||||
help='Show account type summary')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Output as JSON')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
help='Show detailed account information')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialize settings and client
|
||||
print("=" * 60)
|
||||
print("QBO Account ID Lookup")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
settings = Settings()
|
||||
credentials = settings.get_credentials()
|
||||
|
||||
if not credentials.access_token:
|
||||
print("\nError: Not connected to QuickBooks Online.")
|
||||
print("Please connect via the web application first.")
|
||||
sys.exit(1)
|
||||
|
||||
client = QBOClient(credentials)
|
||||
|
||||
if not client.is_authenticated:
|
||||
print("\nError: Authentication failed or token expired.")
|
||||
print("Please reconnect via the web application.")
|
||||
sys.exit(1)
|
||||
|
||||
# Get company info
|
||||
company_info = client.get_company_info()
|
||||
print(f"\nConnected to: {company_info.get('CompanyName', 'Unknown')}")
|
||||
print(f"Environment: {credentials.environment}")
|
||||
print("-" * 60)
|
||||
|
||||
# Fetch accounts
|
||||
print(f"\nFetching accounts...")
|
||||
if args.account_type:
|
||||
print(f" Filter by type: {args.account_type}")
|
||||
if args.search_term:
|
||||
print(f" Search term: {args.search_term}")
|
||||
|
||||
accounts = get_accounts(client, args.account_type, args.search_term)
|
||||
|
||||
print(f"\nFound {len(accounts)} account(s)")
|
||||
print("-" * 60)
|
||||
|
||||
if not accounts:
|
||||
print("No accounts match your criteria.")
|
||||
sys.exit(0)
|
||||
|
||||
# Output results
|
||||
if args.json:
|
||||
print(json.dumps(accounts, indent=2))
|
||||
elif args.verbose:
|
||||
for i, account in enumerate(accounts, 1):
|
||||
print(f"\n[{i}] {account.get('Name')}")
|
||||
print(f" ID: {account.get('Id')}")
|
||||
print(f" Type: {account.get('AccountType')} / {account.get('AccountSubType', '-')}")
|
||||
print(f" Number: {account.get('AcctNum', '-')}")
|
||||
print(f" Balance: ${account.get('CurrentBalance', 0):,.2f}")
|
||||
print(f" Active: {account.get('Active', True)}")
|
||||
if account.get('Description'):
|
||||
print(f" Description: {account.get('Description')}")
|
||||
else:
|
||||
print(format_account_table(accounts))
|
||||
|
||||
# Show summary if requested
|
||||
if args.summary:
|
||||
print_account_types(accounts)
|
||||
|
||||
# Export if requested
|
||||
if args.export_file:
|
||||
filepath = args.export_file
|
||||
if filepath.endswith('.json'):
|
||||
export_to_json(accounts, filepath)
|
||||
else:
|
||||
export_to_csv(accounts, filepath)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Done!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QBO Entity ID Lookup Script
|
||||
============================
|
||||
This script connects to QuickBooks Online and retrieves entity IDs
|
||||
for Accounts, Customers, Vendors, Items, Classes, Departments, etc.
|
||||
|
||||
Usage:
|
||||
python get_qbo_ids.py <entity_type> [--search NAME] [--export FILE]
|
||||
|
||||
Entity Types:
|
||||
account, customer, vendor, item, class, department, term, paymentmethod
|
||||
|
||||
Examples:
|
||||
python get_qbo_ids.py account # List all accounts
|
||||
python get_qbo_ids.py customer # List all customers
|
||||
python get_qbo_ids.py vendor --search "ABC" # Search vendors
|
||||
python get_qbo_ids.py item --export items.csv # Export items to CSV
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path to import from src
|
||||
script_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
from src.config.settings import Settings
|
||||
from src.api.qbo_client import QBOClient
|
||||
|
||||
|
||||
# Entity configurations
|
||||
ENTITY_CONFIG = {
|
||||
'account': {
|
||||
'query_name': 'Account',
|
||||
'display_name': 'Accounts',
|
||||
'columns': ['Id', 'Name', 'AccountType', 'AccountSubType', 'AcctNum', 'CurrentBalance', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'customer': {
|
||||
'query_name': 'Customer',
|
||||
'display_name': 'Customers',
|
||||
'columns': ['Id', 'DisplayName', 'CompanyName', 'PrimaryEmailAddr.Address', 'PrimaryPhone.FreeFormNumber', 'Balance', 'Active'],
|
||||
'search_field': 'DisplayName',
|
||||
'order_by': 'DisplayName',
|
||||
},
|
||||
'vendor': {
|
||||
'query_name': 'Vendor',
|
||||
'display_name': 'Vendors',
|
||||
'columns': ['Id', 'DisplayName', 'CompanyName', 'PrimaryEmailAddr.Address', 'PrimaryPhone.FreeFormNumber', 'Balance', 'Active'],
|
||||
'search_field': 'DisplayName',
|
||||
'order_by': 'DisplayName',
|
||||
},
|
||||
'item': {
|
||||
'query_name': 'Item',
|
||||
'display_name': 'Items/Products',
|
||||
'columns': ['Id', 'Name', 'Type', 'Description', 'UnitPrice', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'class': {
|
||||
'query_name': 'Class',
|
||||
'display_name': 'Classes',
|
||||
'columns': ['Id', 'Name', 'FullyQualifiedName', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'department': {
|
||||
'query_name': 'Department',
|
||||
'display_name': 'Departments',
|
||||
'columns': ['Id', 'Name', 'FullyQualifiedName', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'term': {
|
||||
'query_name': 'Term',
|
||||
'display_name': 'Payment Terms',
|
||||
'columns': ['Id', 'Name', 'DueDays', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'paymentmethod': {
|
||||
'query_name': 'PaymentMethod',
|
||||
'display_name': 'Payment Methods',
|
||||
'columns': ['Id', 'Name', 'Type', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
'employee': {
|
||||
'query_name': 'Employee',
|
||||
'display_name': 'Employees',
|
||||
'columns': ['Id', 'DisplayName', 'GivenName', 'FamilyName', 'PrimaryEmailAddr.Address', 'Active'],
|
||||
'search_field': 'DisplayName',
|
||||
'order_by': 'DisplayName',
|
||||
},
|
||||
'taxcode': {
|
||||
'query_name': 'TaxCode',
|
||||
'display_name': 'Tax Codes',
|
||||
'columns': ['Id', 'Name', 'Description', 'Active'],
|
||||
'search_field': 'Name',
|
||||
'order_by': 'Name',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_nested_value(obj, key_path):
|
||||
"""Get nested value from dictionary using dot notation."""
|
||||
keys = key_path.split('.')
|
||||
value = obj
|
||||
for key in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(key)
|
||||
else:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def fetch_entities(client, entity_type, search_term=None, max_results=1000):
|
||||
"""
|
||||
Fetch entities from QBO.
|
||||
|
||||
Args:
|
||||
client: QBOClient instance
|
||||
entity_type: Type of entity to fetch
|
||||
search_term: Optional search term
|
||||
max_results: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of entity dictionaries
|
||||
"""
|
||||
config = ENTITY_CONFIG.get(entity_type)
|
||||
if not config:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
|
||||
query_name = config['query_name']
|
||||
search_field = config['search_field']
|
||||
order_by = config['order_by']
|
||||
|
||||
# Build query
|
||||
query = f"SELECT * FROM {query_name}"
|
||||
|
||||
if search_term:
|
||||
query += f" WHERE {search_field} LIKE '%{search_term}%'"
|
||||
|
||||
query += f" ORDERBY {order_by} MAXRESULTS {max_results}"
|
||||
|
||||
# Execute query
|
||||
response = client._make_request('GET', 'query', params={'query': query})
|
||||
|
||||
if response and 'QueryResponse' in response:
|
||||
return response['QueryResponse'].get(query_name, [])
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def format_table(entities, columns):
|
||||
"""Format entities as a readable table."""
|
||||
if not entities:
|
||||
return "No records found."
|
||||
|
||||
# Calculate column widths
|
||||
widths = {}
|
||||
for col in columns:
|
||||
col_name = col.split('.')[-1] # Use last part of nested path as header
|
||||
values = [str(get_nested_value(e, col) or '') for e in entities]
|
||||
widths[col] = max(len(col_name), min(40, max(len(v) for v in values) if values else 0))
|
||||
|
||||
# Build header
|
||||
headers = [col.split('.')[-1] for col in columns]
|
||||
header_row = " | ".join(h.ljust(widths[columns[i]]) for i, h in enumerate(headers))
|
||||
separator = "-+-".join("-" * widths[col] for col in columns)
|
||||
|
||||
lines = [header_row, separator]
|
||||
|
||||
# Build data rows
|
||||
for entity in entities:
|
||||
row = []
|
||||
for col in columns:
|
||||
value = get_nested_value(entity, col)
|
||||
if value is None:
|
||||
value = '-'
|
||||
elif isinstance(value, bool):
|
||||
value = 'Yes' if value else 'No'
|
||||
elif isinstance(value, (int, float)) and 'Balance' in col or 'Price' in col:
|
||||
value = f"${value:,.2f}"
|
||||
else:
|
||||
value = str(value)[:40]
|
||||
row.append(value.ljust(widths[col]))
|
||||
lines.append(" | ".join(row))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def export_to_csv(entities, columns, filepath):
|
||||
"""Export entities to CSV file."""
|
||||
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.writer(f)
|
||||
|
||||
# Write header
|
||||
headers = [col.split('.')[-1] for col in columns]
|
||||
writer.writerow(headers)
|
||||
|
||||
# Write data
|
||||
for entity in entities:
|
||||
row = [get_nested_value(entity, col) or '' for col in columns]
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"Exported {len(entities)} records to {filepath}")
|
||||
|
||||
|
||||
def export_to_json(entities, filepath):
|
||||
"""Export entities to JSON file."""
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(entities, f, indent=2)
|
||||
|
||||
print(f"Exported {len(entities)} records to {filepath}")
|
||||
|
||||
|
||||
def print_id_mapping(entities, entity_type):
|
||||
"""Print a simple ID to Name mapping."""
|
||||
config = ENTITY_CONFIG.get(entity_type)
|
||||
name_field = config['search_field'] if config else 'Name'
|
||||
|
||||
print("\nID Mapping (for copy/paste):")
|
||||
print("-" * 50)
|
||||
for entity in entities:
|
||||
entity_id = entity.get('Id')
|
||||
name = get_nested_value(entity, name_field) or entity.get('Name', 'Unknown')
|
||||
print(f"{entity_id}: {name}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Fetch Entity IDs from QuickBooks Online',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Entity Types:
|
||||
account - Chart of Accounts
|
||||
customer - Customers
|
||||
vendor - Vendors
|
||||
item - Products and Services
|
||||
class - Classes (for tracking)
|
||||
department - Departments/Locations
|
||||
term - Payment Terms
|
||||
paymentmethod - Payment Methods
|
||||
employee - Employees
|
||||
taxcode - Tax Codes
|
||||
|
||||
Examples:
|
||||
%(prog)s account # List all accounts
|
||||
%(prog)s customer --search "John" # Search customers
|
||||
%(prog)s vendor --export vendors.csv # Export to CSV
|
||||
%(prog)s item --json # Output as JSON
|
||||
%(prog)s account --type Bank # Filter accounts by type
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('entity_type',
|
||||
choices=list(ENTITY_CONFIG.keys()),
|
||||
help='Type of entity to fetch')
|
||||
parser.add_argument('--search', '-s', dest='search_term',
|
||||
help='Search by name')
|
||||
parser.add_argument('--type', '-t', dest='filter_type',
|
||||
help='Filter by type (for accounts)')
|
||||
parser.add_argument('--export', '-e', dest='export_file',
|
||||
help='Export to file (CSV or JSON)')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Output as JSON')
|
||||
parser.add_argument('--ids-only', action='store_true',
|
||||
help='Show only ID:Name mapping')
|
||||
parser.add_argument('--max', type=int, default=1000,
|
||||
help='Maximum results to fetch (default: 1000)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true',
|
||||
help='Show detailed information')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config = ENTITY_CONFIG[args.entity_type]
|
||||
|
||||
# Initialize settings and client
|
||||
print("=" * 60)
|
||||
print(f"QBO {config['display_name']} Lookup")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
settings = Settings()
|
||||
credentials = settings.get_credentials()
|
||||
|
||||
if not credentials.access_token:
|
||||
print("\nError: Not connected to QuickBooks Online.")
|
||||
print("Please connect via the web application first.")
|
||||
sys.exit(1)
|
||||
|
||||
client = QBOClient(credentials)
|
||||
|
||||
if not client.is_authenticated:
|
||||
print("\nError: Authentication failed or token expired.")
|
||||
print("Please reconnect via the web application.")
|
||||
sys.exit(1)
|
||||
|
||||
# Get company info
|
||||
company_info = client.get_company_info()
|
||||
print(f"\nConnected to: {company_info.get('CompanyName', 'Unknown')}")
|
||||
print(f"Environment: {credentials.environment}")
|
||||
print("-" * 60)
|
||||
|
||||
# Fetch entities
|
||||
print(f"\nFetching {config['display_name'].lower()}...")
|
||||
if args.search_term:
|
||||
print(f" Search term: {args.search_term}")
|
||||
|
||||
entities = fetch_entities(client, args.entity_type, args.search_term, args.max)
|
||||
|
||||
# Filter by type if specified (for accounts)
|
||||
if args.filter_type and args.entity_type == 'account':
|
||||
entities = [e for e in entities if e.get('AccountType') == args.filter_type]
|
||||
|
||||
print(f"\nFound {len(entities)} record(s)")
|
||||
print("-" * 60)
|
||||
|
||||
if not entities:
|
||||
print("No records match your criteria.")
|
||||
sys.exit(0)
|
||||
|
||||
# Output results
|
||||
if args.json:
|
||||
print(json.dumps(entities, indent=2))
|
||||
elif args.ids_only:
|
||||
print_id_mapping(entities, args.entity_type)
|
||||
elif args.verbose:
|
||||
for i, entity in enumerate(entities, 1):
|
||||
print(f"\n[{i}] {get_nested_value(entity, config['search_field']) or entity.get('Name', 'Unknown')}")
|
||||
for key, value in entity.items():
|
||||
if not key.startswith('Meta') and value is not None:
|
||||
print(f" {key}: {value}")
|
||||
else:
|
||||
print(format_table(entities, config['columns']))
|
||||
|
||||
# Export if requested
|
||||
if args.export_file:
|
||||
filepath = args.export_file
|
||||
if filepath.endswith('.json'):
|
||||
export_to_json(entities, filepath)
|
||||
else:
|
||||
export_to_csv(entities, config['columns'], filepath)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Done!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
Flask>=2.3.0
|
||||
Flask-Session>=0.5.0
|
||||
openpyxl>=3.1.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
QuickBooks Online API Client
|
||||
Handles OAuth 2.0 authentication and API operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
import requests
|
||||
|
||||
from src.config.settings import QBOCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QBOEntity:
|
||||
"""Base class for QBO entities."""
|
||||
id: Optional[str] = None
|
||||
sync_token: Optional[str] = None
|
||||
|
||||
def to_qbo_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to QBO API format."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class QBOClient:
|
||||
"""QuickBooks Online API Client with OAuth 2.0 support."""
|
||||
|
||||
# API URLs
|
||||
SANDBOX_BASE_URL = "https://sandbox-quickbooks.api.intuit.com"
|
||||
PRODUCTION_BASE_URL = "https://quickbooks.api.intuit.com"
|
||||
|
||||
AUTHORIZATION_URL = "https://appcenter.intuit.com/connect/oauth2"
|
||||
TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
|
||||
REVOKE_URL = "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"
|
||||
|
||||
# Scopes
|
||||
ACCOUNTING_SCOPE = "com.intuit.quickbooks.accounting"
|
||||
|
||||
def __init__(self, credentials: QBOCredentials):
|
||||
self.credentials = credentials
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Get base URL based on environment."""
|
||||
if self.credentials.environment == "production":
|
||||
return self.PRODUCTION_BASE_URL
|
||||
return self.SANDBOX_BASE_URL
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if we have valid tokens."""
|
||||
if not self.credentials.access_token or not self.credentials.realm_id:
|
||||
return False
|
||||
|
||||
# Check token expiry
|
||||
if self.credentials.token_expiry:
|
||||
try:
|
||||
expiry = datetime.fromisoformat(self.credentials.token_expiry)
|
||||
if datetime.now() >= expiry:
|
||||
# Try to refresh
|
||||
return self.refresh_tokens()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def get_authorization_url(self, state: str = "security_token") -> str:
|
||||
"""Get OAuth authorization URL."""
|
||||
params = {
|
||||
"client_id": self.credentials.client_id,
|
||||
"response_type": "code",
|
||||
"scope": self.ACCOUNTING_SCOPE,
|
||||
"redirect_uri": self.credentials.redirect_uri,
|
||||
"state": state
|
||||
}
|
||||
return f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||
|
||||
def _exchange_code_for_tokens(self, code: str) -> bool:
|
||||
"""Exchange authorization code for tokens."""
|
||||
try:
|
||||
logger.info("Exchanging authorization code for tokens")
|
||||
response = requests.post(
|
||||
self.TOKEN_URL,
|
||||
auth=(self.credentials.client_id, self.credentials.client_secret),
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.credentials.redirect_uri
|
||||
},
|
||||
headers={"Accept": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
self._update_tokens(token_data)
|
||||
logger.info("Token exchange successful")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange exception: {e}")
|
||||
return False
|
||||
|
||||
def refresh_tokens(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
if not self.credentials.refresh_token:
|
||||
logger.warning("No refresh token available")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info("Refreshing access token")
|
||||
response = requests.post(
|
||||
self.TOKEN_URL,
|
||||
auth=(self.credentials.client_id, self.credentials.client_secret),
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.credentials.refresh_token
|
||||
},
|
||||
headers={"Accept": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
token_data = response.json()
|
||||
self._update_tokens(token_data)
|
||||
logger.info("Token refresh successful")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Token refresh failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh exception: {e}")
|
||||
return False
|
||||
|
||||
def _update_tokens(self, token_data: Dict[str, Any]):
|
||||
"""Update credentials with new token data."""
|
||||
self.credentials.access_token = token_data.get("access_token")
|
||||
self.credentials.refresh_token = token_data.get("refresh_token")
|
||||
|
||||
# Calculate expiry
|
||||
expires_in = token_data.get("expires_in", 3600)
|
||||
expiry = datetime.now() + timedelta(seconds=expires_in - 300) # 5 min buffer
|
||||
self.credentials.token_expiry = expiry.isoformat()
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""Revoke tokens and disconnect."""
|
||||
if self.credentials.access_token:
|
||||
try:
|
||||
logger.info("Revoking access token")
|
||||
requests.post(
|
||||
self.REVOKE_URL,
|
||||
auth=(self.credentials.client_id, self.credentials.client_secret),
|
||||
data={"token": self.credentials.access_token},
|
||||
headers={"Accept": "application/json"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token revocation failed: {e}")
|
||||
|
||||
self.credentials.access_token = None
|
||||
self.credentials.refresh_token = None
|
||||
self.credentials.realm_id = None
|
||||
self.credentials.token_expiry = None
|
||||
|
||||
return True
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Make authenticated API request."""
|
||||
if not self.is_authenticated:
|
||||
if not self.refresh_tokens():
|
||||
raise Exception("Not authenticated. Please connect to QuickBooks first.")
|
||||
|
||||
url = f"{self.base_url}/v3/company/{self.credentials.realm_id}/{endpoint}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.credentials.access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data,
|
||||
params=params
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
# Token expired, try to refresh
|
||||
if self.refresh_tokens():
|
||||
headers["Authorization"] = f"Bearer {self.credentials.access_token}"
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data,
|
||||
params=params
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_msg = response.text
|
||||
error_code = None
|
||||
error_detail = None
|
||||
error_element = None
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "Fault" in error_data:
|
||||
fault = error_data["Fault"]
|
||||
errors = fault.get("Error", [])
|
||||
if errors:
|
||||
first_error = errors[0]
|
||||
error_code = first_error.get("code", "")
|
||||
error_msg = first_error.get("Message", error_msg)
|
||||
error_detail = first_error.get("Detail", "")
|
||||
error_element = first_error.get("element", "")
|
||||
|
||||
# Build comprehensive error message
|
||||
parts = []
|
||||
if error_code:
|
||||
parts.append(f"Code: {error_code}")
|
||||
if error_msg:
|
||||
parts.append(f"Message: {error_msg}")
|
||||
if error_detail:
|
||||
parts.append(f"Detail: {error_detail}")
|
||||
if error_element:
|
||||
parts.append(f"Element: {error_element}")
|
||||
|
||||
# Include all errors if multiple
|
||||
if len(errors) > 1:
|
||||
for i, err in enumerate(errors[1:], 2):
|
||||
parts.append(f"Error {i}: {err.get('Message', '')} - {err.get('Detail', '')}")
|
||||
|
||||
error_msg = " | ".join(parts)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.error(f"API Error ({response.status_code}): {error_msg}")
|
||||
logger.error(f"Request URL: {url}")
|
||||
if data:
|
||||
logger.error(f"Request Data: {json.dumps(data, indent=2)}")
|
||||
|
||||
raise Exception(f"QBO API Error ({response.status_code}): {error_msg}")
|
||||
|
||||
return response.json()
|
||||
|
||||
# Query operations
|
||||
def query(self, entity_type: str, where_clause: str = "", max_results: int = 1000) -> List[Dict]:
|
||||
"""Execute a query against QBO."""
|
||||
query = f"SELECT * FROM {entity_type}"
|
||||
if where_clause:
|
||||
query += f" WHERE {where_clause}"
|
||||
query += f" MAXRESULTS {max_results}"
|
||||
|
||||
result = self._make_request("GET", "query", params={"query": query})
|
||||
|
||||
query_response = result.get("QueryResponse", {})
|
||||
return query_response.get(entity_type, [])
|
||||
|
||||
def get_by_id(self, entity_type: str, entity_id: str) -> Optional[Dict]:
|
||||
"""Get entity by ID."""
|
||||
result = self._make_request("GET", f"{entity_type.lower()}/{entity_id}")
|
||||
return result.get(entity_type)
|
||||
|
||||
# Create operations
|
||||
def create(self, entity_type: str, data: Dict) -> Dict:
|
||||
"""Create a new entity."""
|
||||
logger.info(f"Creating {entity_type}")
|
||||
result = self._make_request("POST", entity_type.lower(), data=data)
|
||||
return result.get(entity_type, result)
|
||||
|
||||
def create_batch(self, operations: List[Dict]) -> Dict:
|
||||
"""Execute batch operations."""
|
||||
batch_data = {"BatchItemRequest": operations}
|
||||
result = self._make_request("POST", "batch", data=batch_data)
|
||||
return result
|
||||
|
||||
# Update operations
|
||||
def update(self, entity_type: str, data: Dict) -> Dict:
|
||||
"""Update an existing entity."""
|
||||
logger.info(f"Updating {entity_type}")
|
||||
result = self._make_request("POST", entity_type.lower(), data=data)
|
||||
return result.get(entity_type, result)
|
||||
|
||||
# Entity-specific operations
|
||||
def get_customers(self, active_only: bool = True) -> List[Dict]:
|
||||
"""Get all customers."""
|
||||
where = "Active = true" if active_only else ""
|
||||
return self.query("Customer", where)
|
||||
|
||||
def get_vendors(self, active_only: bool = True) -> List[Dict]:
|
||||
"""Get all vendors."""
|
||||
where = "Active = true" if active_only else ""
|
||||
return self.query("Vendor", where)
|
||||
|
||||
def get_accounts(self, active_only: bool = True) -> List[Dict]:
|
||||
"""Get chart of accounts."""
|
||||
where = "Active = true" if active_only else ""
|
||||
return self.query("Account", where)
|
||||
|
||||
def get_items(self, active_only: bool = True) -> List[Dict]:
|
||||
"""Get all items/products."""
|
||||
where = "Active = true" if active_only else ""
|
||||
return self.query("Item", where)
|
||||
|
||||
def get_company_info(self) -> Dict:
|
||||
"""Get company information."""
|
||||
result = self._make_request("GET", "companyinfo/" + self.credentials.realm_id)
|
||||
return result.get("CompanyInfo", {})
|
||||
|
||||
# Check-specific operations
|
||||
def create_check(self, check_data: Dict) -> Dict:
|
||||
"""Create a check/purchase."""
|
||||
return self.create("Purchase", check_data)
|
||||
|
||||
def get_checks(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[Dict]:
|
||||
"""Get checks (filtered purchases with PaymentType=Check)."""
|
||||
where_parts = ["PaymentType = 'Check'"]
|
||||
if start_date:
|
||||
where_parts.append(f"TxnDate >= '{start_date}'")
|
||||
if end_date:
|
||||
where_parts.append(f"TxnDate <= '{end_date}'")
|
||||
|
||||
return self.query("Purchase", " AND ".join(where_parts))
|
||||
|
||||
# Invoice operations
|
||||
def create_invoice(self, invoice_data: Dict) -> Dict:
|
||||
"""Create an invoice."""
|
||||
return self.create("Invoice", invoice_data)
|
||||
|
||||
def get_invoices(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[Dict]:
|
||||
"""Get invoices."""
|
||||
where_parts = []
|
||||
if start_date:
|
||||
where_parts.append(f"TxnDate >= '{start_date}'")
|
||||
if end_date:
|
||||
where_parts.append(f"TxnDate <= '{end_date}'")
|
||||
|
||||
return self.query("Invoice", " AND ".join(where_parts) if where_parts else "")
|
||||
|
||||
# Bill operations
|
||||
def create_bill(self, bill_data: Dict) -> Dict:
|
||||
"""Create a bill."""
|
||||
return self.create("Bill", bill_data)
|
||||
|
||||
def get_bills(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[Dict]:
|
||||
"""Get bills."""
|
||||
where_parts = []
|
||||
if start_date:
|
||||
where_parts.append(f"TxnDate >= '{start_date}'")
|
||||
if end_date:
|
||||
where_parts.append(f"TxnDate <= '{end_date}'")
|
||||
|
||||
return self.query("Bill", " AND ".join(where_parts) if where_parts else "")
|
||||
|
||||
# Customer/Vendor operations
|
||||
def create_customer(self, customer_data: Dict) -> Dict:
|
||||
"""Create a customer."""
|
||||
return self.create("Customer", customer_data)
|
||||
|
||||
def create_vendor(self, vendor_data: Dict) -> Dict:
|
||||
"""Create a vendor."""
|
||||
return self.create("Vendor", vendor_data)
|
||||
|
||||
# Account operations
|
||||
def create_account(self, account_data: Dict) -> Dict:
|
||||
"""Create an account."""
|
||||
return self.create("Account", account_data)
|
||||
|
||||
def find_entity_by_name(
|
||||
self,
|
||||
entity_type: str,
|
||||
name: str,
|
||||
name_field: str = "DisplayName"
|
||||
) -> Optional[Dict]:
|
||||
"""Find an entity by name."""
|
||||
# Escape single quotes in name
|
||||
escaped_name = name.replace("'", "\\'")
|
||||
results = self.query(entity_type, f"{name_field} = '{escaped_name}'")
|
||||
return results[0] if results else None
|
||||
|
||||
def find_or_create_reference(
|
||||
self,
|
||||
entity_type: str,
|
||||
name: str,
|
||||
create_if_missing: bool = False,
|
||||
additional_data: Optional[Dict] = None
|
||||
) -> Optional[Dict]:
|
||||
"""Find entity by name, optionally creating if not found."""
|
||||
entity = self.find_entity_by_name(entity_type, name)
|
||||
|
||||
if entity:
|
||||
return {"value": entity["Id"], "name": entity.get("DisplayName", entity.get("Name"))}
|
||||
|
||||
if create_if_missing:
|
||||
create_data = {"DisplayName": name}
|
||||
if additional_data:
|
||||
create_data.update(additional_data)
|
||||
|
||||
new_entity = self.create(entity_type, create_data)
|
||||
return {"value": new_entity["Id"], "name": name}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Application settings and configuration management.
|
||||
Handles OAuth tokens, mapping templates, and user preferences.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QBOCredentials:
|
||||
"""QuickBooks Online OAuth credentials."""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
redirect_uri: str = "http://localhost:5000/oauth/callback"
|
||||
environment: str = "sandbox" # 'sandbox' or 'production'
|
||||
|
||||
# Token data (stored securely)
|
||||
access_token: Optional[str] = None
|
||||
refresh_token: Optional[str] = None
|
||||
realm_id: Optional[str] = None
|
||||
token_expiry: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldMapping:
|
||||
"""Single field mapping configuration."""
|
||||
excel_column: str
|
||||
qbo_field: str
|
||||
transform: Optional[str] = None # 'date', 'currency', 'lookup', etc.
|
||||
default_value: Optional[str] = None
|
||||
required: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappingTemplate:
|
||||
"""Complete mapping template for a data type."""
|
||||
name: str
|
||||
data_type: str # 'check', 'invoice', 'bill', 'customer', 'vendor', 'account'
|
||||
mappings: List[FieldMapping] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportSettings:
|
||||
"""Import behavior settings."""
|
||||
skip_duplicates: bool = True
|
||||
duplicate_check_fields: List[str] = field(default_factory=lambda: ["DocNumber"])
|
||||
batch_size: int = 50
|
||||
validate_before_import: bool = True
|
||||
create_missing_references: bool = False # Auto-create missing customers/vendors
|
||||
date_format: str = "%Y-%m-%d"
|
||||
decimal_separator: str = "."
|
||||
thousand_separator: str = ","
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings manager."""
|
||||
|
||||
APP_NAME = "QBOExcelSyncWeb"
|
||||
|
||||
def __init__(self):
|
||||
self.config_dir = self._get_config_dir()
|
||||
self.config_file = self.config_dir / "config.json"
|
||||
self.templates_dir = self.config_dir / "templates"
|
||||
self.credentials_file = self.config_dir / ".credentials"
|
||||
|
||||
# Ensure directories exist
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load or create default config
|
||||
self._config = self._load_config()
|
||||
|
||||
# Credentials (stored separately for security)
|
||||
self._credentials: Optional[QBOCredentials] = None
|
||||
|
||||
logger.info(f"Settings initialized. Config dir: {self.config_dir}")
|
||||
|
||||
def _get_config_dir(self) -> Path:
|
||||
"""Get platform-specific config directory."""
|
||||
# For web app, use a local directory
|
||||
base = Path(__file__).parent.parent.parent
|
||||
return base / "data"
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
default_config = {
|
||||
"import_settings": asdict(ImportSettings()),
|
||||
"recent_files": [],
|
||||
"last_template": None,
|
||||
"qbo_environment": "sandbox",
|
||||
}
|
||||
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
loaded = json.load(f)
|
||||
# Merge with defaults
|
||||
for key, value in default_config.items():
|
||||
if key not in loaded:
|
||||
loaded[key] = value
|
||||
return loaded
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load config: {e}")
|
||||
return default_config
|
||||
|
||||
return default_config
|
||||
|
||||
def save(self):
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(self._config, f, indent=2)
|
||||
logger.info("Configuration saved successfully")
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
|
||||
@property
|
||||
def import_settings(self) -> ImportSettings:
|
||||
"""Get import settings."""
|
||||
return ImportSettings(**self._config.get("import_settings", {}))
|
||||
|
||||
@import_settings.setter
|
||||
def import_settings(self, settings: ImportSettings):
|
||||
"""Set import settings."""
|
||||
self._config["import_settings"] = asdict(settings)
|
||||
self.save()
|
||||
logger.info("Import settings updated")
|
||||
|
||||
@property
|
||||
def recent_files(self) -> List[str]:
|
||||
"""Get list of recently opened files."""
|
||||
return self._config.get("recent_files", [])
|
||||
|
||||
def add_recent_file(self, filepath: str):
|
||||
"""Add file to recent files list."""
|
||||
files = self.recent_files
|
||||
if filepath in files:
|
||||
files.remove(filepath)
|
||||
files.insert(0, filepath)
|
||||
self._config["recent_files"] = files[:10] # Keep only 10 recent
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def qbo_environment(self) -> str:
|
||||
"""Get QBO environment (sandbox/production)."""
|
||||
return self._config.get("qbo_environment", "sandbox")
|
||||
|
||||
@qbo_environment.setter
|
||||
def qbo_environment(self, env: str):
|
||||
"""Set QBO environment."""
|
||||
if env not in ("sandbox", "production"):
|
||||
raise ValueError("Environment must be 'sandbox' or 'production'")
|
||||
self._config["qbo_environment"] = env
|
||||
self.save()
|
||||
logger.info(f"QBO environment set to: {env}")
|
||||
|
||||
# Credential storage (file-based for web app)
|
||||
def get_credentials(self) -> QBOCredentials:
|
||||
"""Get QBO credentials from file storage."""
|
||||
if self._credentials is None:
|
||||
self._credentials = QBOCredentials()
|
||||
|
||||
try:
|
||||
if self.credentials_file.exists():
|
||||
with open(self.credentials_file, 'r') as f:
|
||||
creds_data = json.load(f)
|
||||
self._credentials = QBOCredentials(**creds_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load credentials: {e}")
|
||||
|
||||
return self._credentials
|
||||
|
||||
def save_credentials(self, credentials: QBOCredentials):
|
||||
"""Save QBO credentials to file storage."""
|
||||
self._credentials = credentials
|
||||
try:
|
||||
with open(self.credentials_file, 'w') as f:
|
||||
json.dump(asdict(credentials), f)
|
||||
os.chmod(self.credentials_file, 0o600) # Restrict permissions
|
||||
logger.info("Credentials saved successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save credentials: {e}")
|
||||
|
||||
def clear_credentials(self):
|
||||
"""Clear stored credentials."""
|
||||
self._credentials = None
|
||||
if self.credentials_file.exists():
|
||||
self.credentials_file.unlink()
|
||||
logger.info("Credentials cleared")
|
||||
|
||||
# Template management
|
||||
def get_templates(self, data_type: Optional[str] = None) -> List[MappingTemplate]:
|
||||
"""Get all mapping templates, optionally filtered by data type."""
|
||||
templates = []
|
||||
|
||||
for template_file in self.templates_dir.glob("*.json"):
|
||||
try:
|
||||
with open(template_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
# Reconstruct FieldMapping objects
|
||||
mappings = [FieldMapping(**m) for m in data.get("mappings", [])]
|
||||
template = MappingTemplate(
|
||||
name=data["name"],
|
||||
data_type=data["data_type"],
|
||||
mappings=mappings,
|
||||
created_at=data.get("created_at", ""),
|
||||
updated_at=data.get("updated_at", "")
|
||||
)
|
||||
if data_type is None or template.data_type == data_type:
|
||||
templates.append(template)
|
||||
except (json.JSONDecodeError, IOError, KeyError) as e:
|
||||
logger.warning(f"Failed to load template {template_file}: {e}")
|
||||
continue
|
||||
|
||||
return sorted(templates, key=lambda t: t.name)
|
||||
|
||||
def save_template(self, template: MappingTemplate):
|
||||
"""Save a mapping template."""
|
||||
template.updated_at = datetime.now().isoformat()
|
||||
|
||||
# Convert to serializable format
|
||||
data = {
|
||||
"name": template.name,
|
||||
"data_type": template.data_type,
|
||||
"mappings": [asdict(m) for m in template.mappings],
|
||||
"created_at": template.created_at,
|
||||
"updated_at": template.updated_at
|
||||
}
|
||||
|
||||
# Safe filename
|
||||
safe_name = "".join(c for c in template.name if c.isalnum() or c in " -_").strip()
|
||||
filepath = self.templates_dir / f"{safe_name}.json"
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.info(f"Template saved: {template.name}")
|
||||
|
||||
def delete_template(self, template_name: str):
|
||||
"""Delete a mapping template."""
|
||||
safe_name = "".join(c for c in template_name if c.isalnum() or c in " -_").strip()
|
||||
filepath = self.templates_dir / f"{safe_name}.json"
|
||||
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
logger.info(f"Template deleted: {template_name}")
|
||||
|
||||
def get_default_templates(self) -> Dict[str, MappingTemplate]:
|
||||
"""Get built-in default templates for each data type."""
|
||||
return {
|
||||
"check": MappingTemplate(
|
||||
name="Default Check Import",
|
||||
data_type="check",
|
||||
mappings=[
|
||||
FieldMapping("Payee", "EntityRef.value", required=True),
|
||||
FieldMapping("Bank Acct", "AccountRef.value", required=True),
|
||||
FieldMapping("Date", "TxnDate", transform="date", required=True),
|
||||
FieldMapping("Amount", "Line.Amount", transform="currency", required=True),
|
||||
FieldMapping("Number", "DocNumber"),
|
||||
FieldMapping("Posting Acct", "Line.AccountBasedExpenseLineDetail.AccountRef.value"),
|
||||
FieldMapping("Period Memo", "PrivateNote"),
|
||||
FieldMapping("Period Memo", "Line.Description"),
|
||||
]
|
||||
),
|
||||
"invoice": MappingTemplate(
|
||||
name="Default Invoice Import",
|
||||
data_type="invoice",
|
||||
mappings=[
|
||||
FieldMapping("CustomerRef", "CustomerRef.value", required=True),
|
||||
FieldMapping("TxnDate", "TxnDate", transform="date", required=True),
|
||||
FieldMapping("DueDate", "DueDate", transform="date"),
|
||||
FieldMapping("DocNumber", "DocNumber"),
|
||||
FieldMapping("ItemRef", "Line.SalesItemLineDetail.ItemRef.value"),
|
||||
FieldMapping("Description", "Line.Description"),
|
||||
FieldMapping("Qty", "Line.SalesItemLineDetail.Qty", transform="number"),
|
||||
FieldMapping("UnitPrice", "Line.SalesItemLineDetail.UnitPrice", transform="currency"),
|
||||
FieldMapping("Amount", "Line.Amount", transform="currency"),
|
||||
FieldMapping("PrivateNote", "PrivateNote"),
|
||||
]
|
||||
),
|
||||
"bill": MappingTemplate(
|
||||
name="Default Bill Import",
|
||||
data_type="bill",
|
||||
mappings=[
|
||||
FieldMapping("VendorRef", "VendorRef.value", required=True),
|
||||
FieldMapping("TxnDate", "TxnDate", transform="date", required=True),
|
||||
FieldMapping("DueDate", "DueDate", transform="date"),
|
||||
FieldMapping("DocNumber", "DocNumber"),
|
||||
FieldMapping("AccountRef", "Line.AccountBasedExpenseLineDetail.AccountRef.value"),
|
||||
FieldMapping("Description", "Line.Description"),
|
||||
FieldMapping("Amount", "Line.Amount", transform="currency"),
|
||||
FieldMapping("PrivateNote", "PrivateNote"),
|
||||
]
|
||||
),
|
||||
"customer": MappingTemplate(
|
||||
name="Default Customer Import",
|
||||
data_type="customer",
|
||||
mappings=[
|
||||
FieldMapping("DisplayName", "DisplayName", required=True),
|
||||
FieldMapping("CompanyName", "CompanyName"),
|
||||
FieldMapping("GivenName", "GivenName"),
|
||||
FieldMapping("FamilyName", "FamilyName"),
|
||||
FieldMapping("Email", "PrimaryEmailAddr.Address"),
|
||||
FieldMapping("Phone", "PrimaryPhone.FreeFormNumber"),
|
||||
FieldMapping("Street", "BillAddr.Line1"),
|
||||
FieldMapping("City", "BillAddr.City"),
|
||||
FieldMapping("State", "BillAddr.CountrySubDivisionCode"),
|
||||
FieldMapping("PostalCode", "BillAddr.PostalCode"),
|
||||
FieldMapping("Country", "BillAddr.Country"),
|
||||
]
|
||||
),
|
||||
"vendor": MappingTemplate(
|
||||
name="Default Vendor Import",
|
||||
data_type="vendor",
|
||||
mappings=[
|
||||
FieldMapping("DisplayName", "DisplayName", required=True),
|
||||
FieldMapping("CompanyName", "CompanyName"),
|
||||
FieldMapping("GivenName", "GivenName"),
|
||||
FieldMapping("FamilyName", "FamilyName"),
|
||||
FieldMapping("Email", "PrimaryEmailAddr.Address"),
|
||||
FieldMapping("Phone", "PrimaryPhone.FreeFormNumber"),
|
||||
FieldMapping("Street", "BillAddr.Line1"),
|
||||
FieldMapping("City", "BillAddr.City"),
|
||||
FieldMapping("State", "BillAddr.CountrySubDivisionCode"),
|
||||
FieldMapping("PostalCode", "BillAddr.PostalCode"),
|
||||
FieldMapping("TaxIdentifier", "TaxIdentifier"),
|
||||
]
|
||||
),
|
||||
"account": MappingTemplate(
|
||||
name="Default Chart of Accounts Import",
|
||||
data_type="account",
|
||||
mappings=[
|
||||
FieldMapping("Name", "Name", required=True),
|
||||
FieldMapping("AccountType", "AccountType", required=True),
|
||||
FieldMapping("AccountSubType", "AccountSubType"),
|
||||
FieldMapping("AcctNum", "AcctNum"),
|
||||
FieldMapping("Description", "Description"),
|
||||
FieldMapping("CurrentBalance", "CurrentBalance", transform="currency"),
|
||||
]
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
"""
|
||||
Excel Parser and Data Processor
|
||||
Handles reading Excel files and transforming data for QBO import.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import openpyxl
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationSeverity(Enum):
|
||||
"""Validation issue severity levels."""
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""A single validation issue."""
|
||||
row: int
|
||||
column: str
|
||||
field: str
|
||||
message: str
|
||||
severity: ValidationSeverity = ValidationSeverity.ERROR
|
||||
value: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedRow:
|
||||
"""A parsed row with original and transformed data."""
|
||||
row_number: int
|
||||
original_data: Dict[str, Any]
|
||||
transformed_data: Dict[str, Any] = field(default_factory=dict)
|
||||
qbo_data: Dict[str, Any] = field(default_factory=dict)
|
||||
issues: List[ValidationIssue] = field(default_factory=list)
|
||||
is_valid: bool = True
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return any(i.severity == ValidationSeverity.ERROR for i in self.issues)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return any(i.severity == ValidationSeverity.WARNING for i in self.issues)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
"""Complete result of parsing an Excel file."""
|
||||
filepath: str
|
||||
sheet_name: str
|
||||
columns: List[str]
|
||||
rows: List[ParsedRow]
|
||||
total_rows: int
|
||||
valid_rows: int
|
||||
error_count: int
|
||||
warning_count: int
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
if self.total_rows == 0:
|
||||
return 0.0
|
||||
return (self.valid_rows / self.total_rows) * 100
|
||||
|
||||
|
||||
class DataTransformer:
|
||||
"""Transforms data between Excel and QBO formats."""
|
||||
|
||||
# Common date formats to try
|
||||
DATE_FORMATS = [
|
||||
"%Y-%m-%d",
|
||||
"%m/%d/%Y",
|
||||
"%d/%m/%Y",
|
||||
"%Y/%m/%d",
|
||||
"%m-%d-%Y",
|
||||
"%d-%m-%Y",
|
||||
"%B %d, %Y",
|
||||
"%b %d, %Y",
|
||||
"%d %B %Y",
|
||||
"%d %b %Y",
|
||||
]
|
||||
|
||||
def __init__(self, date_format: str = "%Y-%m-%d"):
|
||||
self.date_format = date_format
|
||||
|
||||
def transform_date(self, value: Any) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Transform value to QBO date format (YYYY-MM-DD).
|
||||
Returns (transformed_value, error_message)
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return None, None
|
||||
|
||||
# Already a date/datetime object
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.strftime("%Y-%m-%d"), None
|
||||
|
||||
# String value - try parsing
|
||||
value_str = str(value).strip()
|
||||
|
||||
for fmt in self.DATE_FORMATS:
|
||||
try:
|
||||
parsed = datetime.strptime(value_str, fmt)
|
||||
return parsed.strftime("%Y-%m-%d"), None
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None, f"Unable to parse date: {value}"
|
||||
|
||||
def transform_currency(self, value: Any) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Transform value to decimal currency format.
|
||||
Returns (transformed_value, error_message)
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return None, None
|
||||
|
||||
# Already numeric
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return str(round(float(value), 2)), None
|
||||
|
||||
# String - clean and parse
|
||||
value_str = str(value).strip()
|
||||
|
||||
# Remove currency symbols and formatting
|
||||
cleaned = re.sub(r'[^\d.\-,]', '', value_str)
|
||||
|
||||
# Handle European format (comma as decimal)
|
||||
if ',' in cleaned and '.' in cleaned:
|
||||
# Determine which is decimal separator based on position
|
||||
if cleaned.rfind(',') > cleaned.rfind('.'):
|
||||
# European: 1.234,56
|
||||
cleaned = cleaned.replace('.', '').replace(',', '.')
|
||||
else:
|
||||
# US: 1,234.56
|
||||
cleaned = cleaned.replace(',', '')
|
||||
elif ',' in cleaned:
|
||||
# Could be either format - assume decimal if only one comma
|
||||
if cleaned.count(',') == 1 and len(cleaned.split(',')[1]) <= 2:
|
||||
cleaned = cleaned.replace(',', '.')
|
||||
else:
|
||||
cleaned = cleaned.replace(',', '')
|
||||
|
||||
try:
|
||||
amount = Decimal(cleaned)
|
||||
return str(round(float(amount), 2)), None
|
||||
except InvalidOperation:
|
||||
return None, f"Unable to parse amount: {value}"
|
||||
|
||||
def transform_number(self, value: Any) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Transform value to number format."""
|
||||
if value is None or value == "":
|
||||
return None, None
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value), None
|
||||
|
||||
try:
|
||||
cleaned = re.sub(r'[^\d.\-]', '', str(value))
|
||||
return str(float(cleaned)), None
|
||||
except ValueError:
|
||||
return None, f"Unable to parse number: {value}"
|
||||
|
||||
def transform_text(self, value: Any) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Transform value to text format."""
|
||||
if value is None:
|
||||
return None, None
|
||||
return str(value).strip(), None
|
||||
|
||||
def transform_boolean(self, value: Any) -> Tuple[Optional[bool], Optional[str]]:
|
||||
"""Transform value to boolean."""
|
||||
if value is None or value == "":
|
||||
return None, None
|
||||
|
||||
if isinstance(value, bool):
|
||||
return value, None
|
||||
|
||||
value_str = str(value).lower().strip()
|
||||
|
||||
if value_str in ('true', 'yes', '1', 'y', 'x'):
|
||||
return True, None
|
||||
elif value_str in ('false', 'no', '0', 'n', ''):
|
||||
return False, None
|
||||
|
||||
return None, f"Unable to parse boolean: {value}"
|
||||
|
||||
def transform_value(
|
||||
self,
|
||||
value: Any,
|
||||
transform_type: Optional[str]
|
||||
) -> Tuple[Any, Optional[str]]:
|
||||
"""Apply transformation based on type."""
|
||||
if transform_type is None or transform_type == "text":
|
||||
return self.transform_text(value)
|
||||
elif transform_type == "date":
|
||||
return self.transform_date(value)
|
||||
elif transform_type == "currency":
|
||||
return self.transform_currency(value)
|
||||
elif transform_type == "number":
|
||||
return self.transform_number(value)
|
||||
elif transform_type == "boolean":
|
||||
return self.transform_boolean(value)
|
||||
else:
|
||||
return self.transform_text(value)
|
||||
|
||||
|
||||
class ExcelParser:
|
||||
"""Parses Excel files for QBO import."""
|
||||
|
||||
def __init__(self):
|
||||
self.transformer = DataTransformer()
|
||||
|
||||
def get_sheet_names(self, filepath: str) -> List[str]:
|
||||
"""Get list of sheet names in Excel file."""
|
||||
logger.info(f"Getting sheet names from: {filepath}")
|
||||
wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True)
|
||||
sheets = wb.sheetnames
|
||||
wb.close()
|
||||
return sheets
|
||||
|
||||
def get_columns(self, filepath: str, sheet_name: Optional[str] = None) -> List[str]:
|
||||
"""Get column headers from Excel file."""
|
||||
wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True)
|
||||
|
||||
if sheet_name:
|
||||
ws = wb[sheet_name]
|
||||
else:
|
||||
ws = wb.active
|
||||
|
||||
columns = []
|
||||
for cell in ws[1]:
|
||||
if cell.value:
|
||||
columns.append(str(cell.value).strip())
|
||||
else:
|
||||
# Use column letter for empty headers
|
||||
columns.append(f"Column_{get_column_letter(cell.column)}")
|
||||
|
||||
wb.close()
|
||||
return columns
|
||||
|
||||
def preview(
|
||||
self,
|
||||
filepath: str,
|
||||
sheet_name: Optional[str] = None,
|
||||
max_rows: int = 10
|
||||
) -> Tuple[List[str], List[Dict[str, Any]]]:
|
||||
"""Preview Excel data (columns and first few rows)."""
|
||||
logger.info(f"Previewing file: {filepath}, sheet: {sheet_name}")
|
||||
wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True)
|
||||
|
||||
if sheet_name:
|
||||
ws = wb[sheet_name]
|
||||
else:
|
||||
ws = wb.active
|
||||
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
|
||||
# Get headers
|
||||
header_row = next(rows_iter, None)
|
||||
if not header_row:
|
||||
wb.close()
|
||||
return [], []
|
||||
|
||||
columns = []
|
||||
for i, val in enumerate(header_row):
|
||||
if val:
|
||||
columns.append(str(val).strip())
|
||||
else:
|
||||
columns.append(f"Column_{get_column_letter(i + 1)}")
|
||||
|
||||
# Get data rows
|
||||
data = []
|
||||
for i, row in enumerate(rows_iter):
|
||||
if i >= max_rows:
|
||||
break
|
||||
|
||||
row_data = {}
|
||||
for j, val in enumerate(row):
|
||||
if j < len(columns):
|
||||
# Convert to JSON-serializable format
|
||||
if isinstance(val, datetime):
|
||||
row_data[columns[j]] = val.isoformat()
|
||||
elif isinstance(val, date):
|
||||
row_data[columns[j]] = val.isoformat()
|
||||
else:
|
||||
row_data[columns[j]] = val
|
||||
|
||||
# Skip completely empty rows
|
||||
if any(v is not None and v != "" for v in row_data.values()):
|
||||
data.append(row_data)
|
||||
|
||||
wb.close()
|
||||
return columns, data
|
||||
|
||||
def parse(
|
||||
self,
|
||||
filepath: str,
|
||||
mappings: List['FieldMapping'],
|
||||
sheet_name: Optional[str] = None,
|
||||
skip_empty_rows: bool = True,
|
||||
progress_callback: Optional[Callable[[int, int], None]] = None
|
||||
) -> ParseResult:
|
||||
"""
|
||||
Parse Excel file using field mappings.
|
||||
|
||||
Args:
|
||||
filepath: Path to Excel file
|
||||
mappings: List of FieldMapping objects
|
||||
sheet_name: Specific sheet to parse
|
||||
skip_empty_rows: Whether to skip empty rows
|
||||
progress_callback: Optional callback(current_row, total_rows)
|
||||
|
||||
Returns:
|
||||
ParseResult with all parsed data
|
||||
"""
|
||||
from src.config.settings import FieldMapping
|
||||
|
||||
logger.info(f"Parsing file: {filepath}, sheet: {sheet_name}")
|
||||
|
||||
wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True)
|
||||
|
||||
if sheet_name:
|
||||
ws = wb[sheet_name]
|
||||
else:
|
||||
ws = wb.active
|
||||
sheet_name = ws.title
|
||||
|
||||
# Count total rows (excluding header)
|
||||
total_rows = ws.max_row - 1 if ws.max_row else 0
|
||||
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
|
||||
# Get headers
|
||||
header_row = next(rows_iter, None)
|
||||
if not header_row:
|
||||
wb.close()
|
||||
return ParseResult(
|
||||
filepath=filepath,
|
||||
sheet_name=sheet_name,
|
||||
columns=[],
|
||||
rows=[],
|
||||
total_rows=0,
|
||||
valid_rows=0,
|
||||
error_count=0,
|
||||
warning_count=0
|
||||
)
|
||||
|
||||
columns = []
|
||||
col_index_map = {}
|
||||
for i, val in enumerate(header_row):
|
||||
col_name = str(val).strip() if val else f"Column_{get_column_letter(i + 1)}"
|
||||
columns.append(col_name)
|
||||
col_index_map[col_name] = i
|
||||
|
||||
# Create mapping lookup
|
||||
mapping_lookup = {m.excel_column: m for m in mappings if m.excel_column}
|
||||
|
||||
# Parse rows
|
||||
parsed_rows = []
|
||||
valid_count = 0
|
||||
error_count = 0
|
||||
warning_count = 0
|
||||
|
||||
for row_num, row in enumerate(rows_iter, start=2):
|
||||
if progress_callback:
|
||||
progress_callback(row_num - 1, total_rows)
|
||||
|
||||
# Build original data dict
|
||||
original_data = {}
|
||||
for i, val in enumerate(row):
|
||||
if i < len(columns):
|
||||
original_data[columns[i]] = val
|
||||
|
||||
# Skip empty rows
|
||||
if skip_empty_rows and all(
|
||||
v is None or v == "" for v in original_data.values()
|
||||
):
|
||||
continue
|
||||
|
||||
# Create parsed row
|
||||
parsed_row = ParsedRow(
|
||||
row_number=row_num,
|
||||
original_data=original_data
|
||||
)
|
||||
|
||||
# Apply mappings and transformations
|
||||
for mapping in mappings:
|
||||
if not mapping.excel_column:
|
||||
continue
|
||||
|
||||
# Get original value
|
||||
original_value = original_data.get(mapping.excel_column)
|
||||
|
||||
# Use default if empty
|
||||
if (original_value is None or original_value == "") and mapping.default_value:
|
||||
original_value = mapping.default_value
|
||||
|
||||
# Validate required fields
|
||||
if mapping.required and (original_value is None or original_value == ""):
|
||||
parsed_row.issues.append(ValidationIssue(
|
||||
row=row_num,
|
||||
column=mapping.excel_column,
|
||||
field=mapping.qbo_field,
|
||||
message=f"Required field '{mapping.excel_column}' is empty",
|
||||
severity=ValidationSeverity.ERROR
|
||||
))
|
||||
parsed_row.is_valid = False
|
||||
continue
|
||||
|
||||
# Transform value
|
||||
transformed, error = self.transformer.transform_value(
|
||||
original_value,
|
||||
mapping.transform
|
||||
)
|
||||
|
||||
if error:
|
||||
parsed_row.issues.append(ValidationIssue(
|
||||
row=row_num,
|
||||
column=mapping.excel_column,
|
||||
field=mapping.qbo_field,
|
||||
message=error,
|
||||
severity=ValidationSeverity.ERROR,
|
||||
value=original_value
|
||||
))
|
||||
parsed_row.is_valid = False
|
||||
else:
|
||||
parsed_row.transformed_data[mapping.qbo_field] = transformed
|
||||
|
||||
# Build QBO data structure
|
||||
if parsed_row.is_valid:
|
||||
parsed_row.qbo_data = self._build_qbo_structure(
|
||||
parsed_row.transformed_data,
|
||||
mappings
|
||||
)
|
||||
|
||||
# Update counts
|
||||
if parsed_row.has_errors:
|
||||
error_count += len([i for i in parsed_row.issues if i.severity == ValidationSeverity.ERROR])
|
||||
if parsed_row.has_warnings:
|
||||
warning_count += len([i for i in parsed_row.issues if i.severity == ValidationSeverity.WARNING])
|
||||
if parsed_row.is_valid:
|
||||
valid_count += 1
|
||||
|
||||
parsed_rows.append(parsed_row)
|
||||
|
||||
wb.close()
|
||||
|
||||
logger.info(f"Parsed {len(parsed_rows)} rows, {valid_count} valid, {error_count} errors")
|
||||
|
||||
return ParseResult(
|
||||
filepath=filepath,
|
||||
sheet_name=sheet_name,
|
||||
columns=columns,
|
||||
rows=parsed_rows,
|
||||
total_rows=len(parsed_rows),
|
||||
valid_rows=valid_count,
|
||||
error_count=error_count,
|
||||
warning_count=warning_count
|
||||
)
|
||||
|
||||
def _build_qbo_structure(
|
||||
self,
|
||||
transformed_data: Dict[str, Any],
|
||||
mappings: List['FieldMapping']
|
||||
) -> Dict[str, Any]:
|
||||
"""Build nested QBO data structure from flat transformed data."""
|
||||
result = {}
|
||||
|
||||
for qbo_field, value in transformed_data.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Handle nested fields (e.g., "CustomerRef.value" or "Line.Amount")
|
||||
parts = qbo_field.split('.')
|
||||
|
||||
current = result
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
current[parts[-1]] = value
|
||||
|
||||
return result
|
||||
|
||||
def validate_mappings(
|
||||
self,
|
||||
filepath: str,
|
||||
mappings: List['FieldMapping'],
|
||||
sheet_name: Optional[str] = None
|
||||
) -> List[ValidationIssue]:
|
||||
"""Validate that mappings match Excel columns."""
|
||||
columns = self.get_columns(filepath, sheet_name)
|
||||
issues = []
|
||||
|
||||
for mapping in mappings:
|
||||
if mapping.excel_column and mapping.excel_column not in columns:
|
||||
issues.append(ValidationIssue(
|
||||
row=0,
|
||||
column=mapping.excel_column,
|
||||
field=mapping.qbo_field,
|
||||
message=f"Column '{mapping.excel_column}' not found in Excel file",
|
||||
severity=ValidationSeverity.ERROR
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
class QBODataBuilder:
|
||||
"""Builds QBO-ready data structures from parsed rows."""
|
||||
|
||||
@staticmethod
|
||||
def build_check(row_data: Dict[str, Any], line_items: List[Dict] = None) -> Dict[str, Any]:
|
||||
"""Build a Check/Purchase entity."""
|
||||
check = {
|
||||
"PaymentType": "Check" # Required field - always set
|
||||
}
|
||||
|
||||
# Direct field mappings
|
||||
direct_fields = [
|
||||
"PayeeRef", "BankAccountRef", "TxnDate", "DocNumber",
|
||||
"PrivateNote", "TotalAmt", "Memo"
|
||||
]
|
||||
|
||||
for field in direct_fields:
|
||||
if field in row_data and row_data[field]:
|
||||
check[field] = row_data[field]
|
||||
|
||||
# Build line items
|
||||
if line_items:
|
||||
check["Line"] = line_items
|
||||
elif "Line" in row_data:
|
||||
# Single line from row
|
||||
line = row_data["Line"]
|
||||
if isinstance(line, dict) and ("AccountBasedExpenseLineDetail" in line or "Amount" in line):
|
||||
line_item = {
|
||||
"DetailType": "AccountBasedExpenseLineDetail",
|
||||
"Amount": line.get("Amount", 0),
|
||||
"Description": line.get("Description", "")
|
||||
}
|
||||
# Handle AccountBasedExpenseLineDetail
|
||||
if "AccountBasedExpenseLineDetail" in line:
|
||||
line_item["AccountBasedExpenseLineDetail"] = line["AccountBasedExpenseLineDetail"]
|
||||
elif "AccountRef" in line:
|
||||
line_item["AccountBasedExpenseLineDetail"] = {"AccountRef": line["AccountRef"]}
|
||||
check["Line"] = [line_item]
|
||||
|
||||
return check
|
||||
|
||||
@staticmethod
|
||||
def build_invoice(row_data: Dict[str, Any], line_items: List[Dict] = None) -> Dict[str, Any]:
|
||||
"""Build an Invoice entity."""
|
||||
invoice = {}
|
||||
|
||||
direct_fields = [
|
||||
"CustomerRef", "TxnDate", "DueDate", "DocNumber",
|
||||
"PrivateNote", "Memo", "BillEmail"
|
||||
]
|
||||
|
||||
for field in direct_fields:
|
||||
if field in row_data and row_data[field]:
|
||||
invoice[field] = row_data[field]
|
||||
|
||||
if line_items:
|
||||
invoice["Line"] = line_items
|
||||
elif "Line" in row_data:
|
||||
line = row_data["Line"]
|
||||
invoice["Line"] = [{
|
||||
"DetailType": "SalesItemLineDetail",
|
||||
"Amount": line.get("Amount", 0),
|
||||
"SalesItemLineDetail": line.get("SalesItemLineDetail", {}),
|
||||
"Description": line.get("Description", "")
|
||||
}]
|
||||
|
||||
return invoice
|
||||
|
||||
@staticmethod
|
||||
def build_bill(row_data: Dict[str, Any], line_items: List[Dict] = None) -> Dict[str, Any]:
|
||||
"""Build a Bill entity."""
|
||||
bill = {}
|
||||
|
||||
direct_fields = [
|
||||
"VendorRef", "TxnDate", "DueDate", "DocNumber",
|
||||
"PrivateNote", "Memo"
|
||||
]
|
||||
|
||||
for field in direct_fields:
|
||||
if field in row_data and row_data[field]:
|
||||
bill[field] = row_data[field]
|
||||
|
||||
if line_items:
|
||||
bill["Line"] = line_items
|
||||
elif "Line" in row_data:
|
||||
line = row_data["Line"]
|
||||
bill["Line"] = [{
|
||||
"DetailType": "AccountBasedExpenseLineDetail",
|
||||
"Amount": line.get("Amount", 0),
|
||||
"AccountBasedExpenseLineDetail": line.get("AccountBasedExpenseLineDetail", {}),
|
||||
"Description": line.get("Description", "")
|
||||
}]
|
||||
|
||||
return bill
|
||||
|
||||
@staticmethod
|
||||
def build_customer(row_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a Customer entity."""
|
||||
customer = {}
|
||||
|
||||
direct_fields = [
|
||||
"DisplayName", "CompanyName", "GivenName", "FamilyName",
|
||||
"Title", "Suffix", "Notes"
|
||||
]
|
||||
|
||||
for field in direct_fields:
|
||||
if field in row_data and row_data[field]:
|
||||
customer[field] = row_data[field]
|
||||
|
||||
# Handle nested address
|
||||
if "BillAddr" in row_data:
|
||||
customer["BillAddr"] = row_data["BillAddr"]
|
||||
|
||||
# Handle contact info
|
||||
if "PrimaryEmailAddr" in row_data:
|
||||
customer["PrimaryEmailAddr"] = row_data["PrimaryEmailAddr"]
|
||||
|
||||
if "PrimaryPhone" in row_data:
|
||||
customer["PrimaryPhone"] = row_data["PrimaryPhone"]
|
||||
|
||||
return customer
|
||||
|
||||
@staticmethod
|
||||
def build_vendor(row_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a Vendor entity."""
|
||||
vendor = {}
|
||||
|
||||
direct_fields = [
|
||||
"DisplayName", "CompanyName", "GivenName", "FamilyName",
|
||||
"Title", "Suffix", "Notes", "TaxIdentifier", "AcctNum"
|
||||
]
|
||||
|
||||
for field in direct_fields:
|
||||
if field in row_data and row_data[field]:
|
||||
vendor[field] = row_data[field]
|
||||
|
||||
if "BillAddr" in row_data:
|
||||
vendor["BillAddr"] = row_data["BillAddr"]
|
||||
|
||||
if "PrimaryEmailAddr" in row_data:
|
||||
vendor["PrimaryEmailAddr"] = row_data["PrimaryEmailAddr"]
|
||||
|
||||
if "PrimaryPhone" in row_data:
|
||||
vendor["PrimaryPhone"] = row_data["PrimaryPhone"]
|
||||
|
||||
return vendor
|
||||
|
||||
@staticmethod
|
||||
def build_account(row_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build an Account entity."""
|
||||
account = {}
|
||||
|
||||
fields = [
|
||||
"Name", "AccountType", "AccountSubType", "AcctNum",
|
||||
"Description", "CurrentBalance"
|
||||
]
|
||||
|
||||
for field in fields:
|
||||
if field in row_data and row_data[field]:
|
||||
account[field] = row_data[field]
|
||||
|
||||
return account
|
||||
@@ -0,0 +1,719 @@
|
||||
"""
|
||||
Import Processor
|
||||
Handles batch import operations with validation, duplicate detection, and error handling.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Callable, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.excel_parser import ParsedRow, ParseResult, QBODataBuilder, ValidationIssue, ValidationSeverity
|
||||
from src.api.qbo_client import QBOClient
|
||||
from src.config.settings import ImportSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImportStatus(Enum):
|
||||
"""Status of an individual import operation."""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
DUPLICATE = "duplicate"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportRecord:
|
||||
"""Record of a single import attempt."""
|
||||
row_number: int
|
||||
original_data: Dict[str, Any]
|
||||
qbo_data: Dict[str, Any]
|
||||
status: ImportStatus = ImportStatus.PENDING
|
||||
qbo_id: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
"""Complete result of an import operation."""
|
||||
data_type: str
|
||||
total_records: int
|
||||
successful: int
|
||||
failed: int
|
||||
skipped: int
|
||||
duplicates: int
|
||||
records: List[ImportRecord] = field(default_factory=list)
|
||||
start_time: Optional[str] = None
|
||||
end_time: Optional[str] = None
|
||||
duration_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def success_rate(self) -> float:
|
||||
if self.total_records == 0:
|
||||
return 0.0
|
||||
return (self.successful / self.total_records) * 100
|
||||
|
||||
|
||||
class ReferenceResolver:
|
||||
"""Resolves entity references (customers, vendors, accounts) for import."""
|
||||
|
||||
def __init__(self, qbo_client: QBOClient, settings: ImportSettings):
|
||||
self.client = qbo_client
|
||||
self.settings = settings
|
||||
|
||||
# Caches
|
||||
self._customers: Optional[Dict[str, Dict]] = None
|
||||
self._vendors: Optional[Dict[str, Dict]] = None
|
||||
self._accounts: Optional[Dict[str, Dict]] = None
|
||||
self._items: Optional[Dict[str, Dict]] = None
|
||||
|
||||
def refresh_cache(self):
|
||||
"""Refresh all reference caches."""
|
||||
logger.info("Refreshing reference caches")
|
||||
self._customers = None
|
||||
self._vendors = None
|
||||
self._accounts = None
|
||||
self._items = None
|
||||
|
||||
@property
|
||||
def customers(self) -> Dict[str, Dict]:
|
||||
"""Get customers cache (name -> entity)."""
|
||||
if self._customers is None:
|
||||
self._customers = {}
|
||||
for c in self.client.get_customers():
|
||||
name = c.get("DisplayName", "").lower()
|
||||
self._customers[name] = {"value": c["Id"], "name": c.get("DisplayName")}
|
||||
return self._customers
|
||||
|
||||
@property
|
||||
def vendors(self) -> Dict[str, Dict]:
|
||||
"""Get vendors cache."""
|
||||
if self._vendors is None:
|
||||
self._vendors = {}
|
||||
for v in self.client.get_vendors():
|
||||
name = v.get("DisplayName", "").lower()
|
||||
self._vendors[name] = {"value": v["Id"], "name": v.get("DisplayName")}
|
||||
return self._vendors
|
||||
|
||||
@property
|
||||
def accounts(self) -> Dict[str, Dict]:
|
||||
"""Get accounts cache."""
|
||||
if self._accounts is None:
|
||||
self._accounts = {}
|
||||
for a in self.client.get_accounts():
|
||||
name = a.get("Name", "")
|
||||
acct_num = a.get("AcctNum", "")
|
||||
acct_id = a["Id"]
|
||||
|
||||
# Index by multiple keys for flexible matching
|
||||
self._accounts[name.lower()] = {"value": acct_id, "name": name}
|
||||
|
||||
if acct_num:
|
||||
self._accounts[acct_num.lower()] = {"value": acct_id, "name": name}
|
||||
self._accounts[str(acct_num)] = {"value": acct_id, "name": name}
|
||||
|
||||
combined_key = f"{acct_num} · {name}".lower() if acct_num else name.lower()
|
||||
self._accounts[combined_key] = {"value": acct_id, "name": name}
|
||||
|
||||
return self._accounts
|
||||
|
||||
def _extract_account_identifier(self, value: str) -> str:
|
||||
"""Extract account number or name from formatted strings."""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
value = value.strip()
|
||||
|
||||
if " · " in value:
|
||||
parts = value.split(" · ", 1)
|
||||
acct_num = parts[0].strip()
|
||||
if acct_num.isdigit():
|
||||
return acct_num
|
||||
|
||||
if " - " in value:
|
||||
parts = value.split(" - ", 1)
|
||||
acct_num = parts[0].strip()
|
||||
if acct_num.isdigit():
|
||||
return acct_num
|
||||
|
||||
return value
|
||||
|
||||
@property
|
||||
def items(self) -> Dict[str, Dict]:
|
||||
"""Get items/products cache."""
|
||||
if self._items is None:
|
||||
self._items = {}
|
||||
for i in self.client.get_items():
|
||||
name = i.get("Name", "").lower()
|
||||
self._items[name] = {"value": i["Id"], "name": i.get("Name")}
|
||||
return self._items
|
||||
|
||||
def resolve_customer(self, name: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Resolve customer name to reference."""
|
||||
if not name:
|
||||
return None, "Customer name is required"
|
||||
|
||||
ref = self.customers.get(name.lower())
|
||||
if ref:
|
||||
return ref, None
|
||||
|
||||
if self.settings.create_missing_references:
|
||||
try:
|
||||
new_customer = self.client.create_customer({"DisplayName": name})
|
||||
ref = {"value": new_customer["Id"], "name": name}
|
||||
self._customers[name.lower()] = ref
|
||||
logger.info(f"Created new customer: {name}")
|
||||
return ref, None
|
||||
except Exception as e:
|
||||
return None, f"Failed to create customer: {str(e)}"
|
||||
|
||||
return None, f"Customer not found: {name}"
|
||||
|
||||
def resolve_vendor(self, name: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Resolve vendor name to reference."""
|
||||
if not name:
|
||||
return None, "Vendor name is required"
|
||||
|
||||
clean_name = name.strip()
|
||||
|
||||
ref = self.vendors.get(clean_name.lower())
|
||||
if ref:
|
||||
return ref, None
|
||||
|
||||
if self.settings.create_missing_references:
|
||||
try:
|
||||
new_vendor = self.client.create_vendor({"DisplayName": clean_name})
|
||||
ref = {"value": new_vendor["Id"], "name": clean_name}
|
||||
self._vendors[clean_name.lower()] = ref
|
||||
logger.info(f"Created new vendor: {clean_name}")
|
||||
return ref, None
|
||||
except Exception as e:
|
||||
return None, f"Failed to create vendor: {str(e)}"
|
||||
|
||||
return None, f"Vendor not found: {clean_name}"
|
||||
|
||||
def resolve_account(self, name_or_num: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Resolve account name or number to reference."""
|
||||
if not name_or_num:
|
||||
return None, "Account name/number is required"
|
||||
|
||||
ref = self.accounts.get(name_or_num.lower())
|
||||
if ref:
|
||||
return ref, None
|
||||
|
||||
extracted = self._extract_account_identifier(name_or_num)
|
||||
if extracted != name_or_num:
|
||||
ref = self.accounts.get(extracted.lower())
|
||||
if ref:
|
||||
return ref, None
|
||||
ref = self.accounts.get(extracted)
|
||||
if ref:
|
||||
return ref, None
|
||||
|
||||
return None, f"Account not found: {name_or_num}"
|
||||
|
||||
def resolve_item(self, name: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Resolve item/product name to reference."""
|
||||
if not name:
|
||||
return None, "Item name is required"
|
||||
|
||||
ref = self.items.get(name.lower())
|
||||
if ref:
|
||||
return ref, None
|
||||
|
||||
return None, f"Item not found: {name}"
|
||||
|
||||
|
||||
class DuplicateChecker:
|
||||
"""Checks for duplicate records before import."""
|
||||
|
||||
def __init__(self, qbo_client: QBOClient, settings: ImportSettings):
|
||||
self.client = qbo_client
|
||||
self.settings = settings
|
||||
self._existing_doc_numbers: Dict[str, set] = {}
|
||||
|
||||
def load_existing(self, data_type: str, date_range: Tuple[str, str] = None):
|
||||
"""Load existing document numbers for duplicate checking."""
|
||||
self._existing_doc_numbers[data_type] = set()
|
||||
|
||||
try:
|
||||
if data_type == "check":
|
||||
records = self.client.get_checks(
|
||||
start_date=date_range[0] if date_range else None,
|
||||
end_date=date_range[1] if date_range else None
|
||||
)
|
||||
elif data_type == "invoice":
|
||||
records = self.client.get_invoices(
|
||||
start_date=date_range[0] if date_range else None,
|
||||
end_date=date_range[1] if date_range else None
|
||||
)
|
||||
elif data_type == "bill":
|
||||
records = self.client.get_bills(
|
||||
start_date=date_range[0] if date_range else None,
|
||||
end_date=date_range[1] if date_range else None
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
for record in records:
|
||||
for field in self.settings.duplicate_check_fields:
|
||||
if field in record and record[field]:
|
||||
self._existing_doc_numbers[data_type].add(str(record[field]).lower())
|
||||
|
||||
logger.info(f"Loaded {len(self._existing_doc_numbers[data_type])} existing {data_type} records for duplicate check")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load existing records for duplicate check: {e}")
|
||||
|
||||
def is_duplicate(self, data_type: str, record_data: Dict) -> bool:
|
||||
"""Check if record is a duplicate."""
|
||||
if not self.settings.skip_duplicates:
|
||||
return False
|
||||
|
||||
existing = self._existing_doc_numbers.get(data_type, set())
|
||||
|
||||
for field in self.settings.duplicate_check_fields:
|
||||
value = record_data.get(field)
|
||||
if value and str(value).lower() in existing:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def add_imported(self, data_type: str, record_data: Dict):
|
||||
"""Add newly imported record to duplicate tracker."""
|
||||
if data_type not in self._existing_doc_numbers:
|
||||
self._existing_doc_numbers[data_type] = set()
|
||||
|
||||
for field in self.settings.duplicate_check_fields:
|
||||
if field in record_data and record_data[field]:
|
||||
self._existing_doc_numbers[data_type].add(str(record_data[field]).lower())
|
||||
|
||||
|
||||
class ImportProcessor:
|
||||
"""Main import processor with validation and batch operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
qbo_client: QBOClient,
|
||||
settings: ImportSettings,
|
||||
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
||||
log_callback: Optional[Callable[[str, str], None]] = None
|
||||
):
|
||||
self.client = qbo_client
|
||||
self.settings = settings
|
||||
self.progress_callback = progress_callback
|
||||
self.log_callback = log_callback
|
||||
|
||||
self.resolver = ReferenceResolver(qbo_client, settings)
|
||||
self.duplicate_checker = DuplicateChecker(qbo_client, settings)
|
||||
|
||||
def _log(self, level: str, message: str):
|
||||
"""Log a message."""
|
||||
if self.log_callback:
|
||||
self.log_callback(level, message)
|
||||
|
||||
if level == "error":
|
||||
logger.error(message)
|
||||
elif level == "warning":
|
||||
logger.warning(message)
|
||||
else:
|
||||
logger.info(message)
|
||||
|
||||
def _progress(self, current: int, total: int, message: str = ""):
|
||||
"""Report progress."""
|
||||
if self.progress_callback:
|
||||
self.progress_callback(current, total, message)
|
||||
|
||||
def validate_and_resolve(
|
||||
self,
|
||||
parse_result: ParseResult,
|
||||
data_type: str
|
||||
) -> List[ImportRecord]:
|
||||
"""Validate parsed data and resolve references."""
|
||||
records = []
|
||||
|
||||
self._log("info", f"Validating {len(parse_result.rows)} records...")
|
||||
self._log("info", "Loading reference data from QuickBooks...")
|
||||
|
||||
# Refresh reference caches
|
||||
self.resolver.refresh_cache()
|
||||
|
||||
# Load existing records for duplicate check
|
||||
if self.settings.skip_duplicates:
|
||||
self._log("info", "Checking for duplicates...")
|
||||
self.duplicate_checker.load_existing(data_type)
|
||||
|
||||
for i, row in enumerate(parse_result.rows):
|
||||
self._progress(i + 1, len(parse_result.rows), f"Validating row {row.row_number}")
|
||||
|
||||
record = ImportRecord(
|
||||
row_number=row.row_number,
|
||||
original_data=row.original_data,
|
||||
qbo_data=row.qbo_data.copy()
|
||||
)
|
||||
|
||||
# Skip rows with parsing errors
|
||||
if not row.is_valid:
|
||||
record.status = ImportStatus.FAILED
|
||||
record.error_message = "; ".join(
|
||||
issue.message for issue in row.issues
|
||||
if issue.severity == ValidationSeverity.ERROR
|
||||
)
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
# Resolve references based on data type
|
||||
error = self._resolve_references(record.qbo_data, data_type)
|
||||
if error:
|
||||
record.status = ImportStatus.FAILED
|
||||
record.error_message = error
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
# Check for duplicates
|
||||
if self.duplicate_checker.is_duplicate(data_type, record.qbo_data):
|
||||
record.status = ImportStatus.DUPLICATE
|
||||
record.error_message = "Duplicate record found"
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
record.status = ImportStatus.PENDING
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
def _resolve_references(self, qbo_data: Dict, data_type: str) -> Optional[str]:
|
||||
"""Resolve entity references in QBO data. Returns error message or None."""
|
||||
errors = []
|
||||
|
||||
if data_type == "check":
|
||||
# Resolve payee/vendor (EntityRef for Purchase API)
|
||||
entity_key = "EntityRef" if "EntityRef" in qbo_data else "PayeeRef"
|
||||
if entity_key in qbo_data:
|
||||
payee_val = qbo_data[entity_key]
|
||||
if isinstance(payee_val, dict):
|
||||
payee_name = payee_val.get("value", payee_val.get("name", ""))
|
||||
else:
|
||||
payee_name = str(payee_val)
|
||||
|
||||
if payee_name and not str(payee_name).isdigit():
|
||||
ref, err = self.resolver.resolve_vendor(payee_name)
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
qbo_data["EntityRef"] = ref
|
||||
if entity_key == "PayeeRef":
|
||||
del qbo_data["PayeeRef"]
|
||||
elif payee_name and str(payee_name).isdigit():
|
||||
qbo_data["EntityRef"] = {"value": payee_name}
|
||||
if entity_key == "PayeeRef":
|
||||
del qbo_data["PayeeRef"]
|
||||
|
||||
# Resolve bank account
|
||||
acct_key = "AccountRef" if "AccountRef" in qbo_data else "BankAccountRef"
|
||||
if acct_key in qbo_data:
|
||||
acct_val = qbo_data[acct_key]
|
||||
if isinstance(acct_val, dict):
|
||||
acct = acct_val.get("value", acct_val.get("name", ""))
|
||||
else:
|
||||
acct = str(acct_val)
|
||||
|
||||
if acct and not str(acct).isdigit():
|
||||
ref, err = self.resolver.resolve_account(acct)
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
qbo_data["AccountRef"] = ref
|
||||
if acct_key == "BankAccountRef":
|
||||
del qbo_data["BankAccountRef"]
|
||||
elif acct and str(acct).isdigit():
|
||||
qbo_data["AccountRef"] = {"value": acct}
|
||||
if acct_key == "BankAccountRef":
|
||||
del qbo_data["BankAccountRef"]
|
||||
|
||||
# Build Line items properly
|
||||
if "Line" in qbo_data and isinstance(qbo_data["Line"], dict):
|
||||
line = qbo_data["Line"]
|
||||
amount = line.get("Amount", 0)
|
||||
if isinstance(amount, str):
|
||||
try:
|
||||
amount = float(amount)
|
||||
except ValueError:
|
||||
amount = 0.0
|
||||
|
||||
description = line.get("Description", "")
|
||||
|
||||
account_ref = None
|
||||
if "AccountBasedExpenseLineDetail" in line:
|
||||
detail = line["AccountBasedExpenseLineDetail"]
|
||||
if "AccountRef" in detail:
|
||||
acct_val = detail["AccountRef"]
|
||||
if isinstance(acct_val, dict):
|
||||
acct = acct_val.get("value", "")
|
||||
else:
|
||||
acct = str(acct_val)
|
||||
|
||||
if acct and not str(acct).isdigit():
|
||||
ref, err = self.resolver.resolve_account(acct)
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
account_ref = ref
|
||||
elif acct and str(acct).isdigit():
|
||||
account_ref = {"value": acct}
|
||||
|
||||
line_item = {
|
||||
"DetailType": "AccountBasedExpenseLineDetail",
|
||||
"Amount": amount,
|
||||
"Description": description,
|
||||
"AccountBasedExpenseLineDetail": {}
|
||||
}
|
||||
|
||||
if account_ref:
|
||||
line_item["AccountBasedExpenseLineDetail"]["AccountRef"] = account_ref
|
||||
|
||||
qbo_data["Line"] = [line_item]
|
||||
|
||||
elif data_type == "invoice":
|
||||
if "CustomerRef" in qbo_data:
|
||||
cust_name = qbo_data["CustomerRef"].get("value") if isinstance(qbo_data["CustomerRef"], dict) else qbo_data["CustomerRef"]
|
||||
if cust_name and not str(cust_name).isdigit():
|
||||
ref, err = self.resolver.resolve_customer(str(cust_name))
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
qbo_data["CustomerRef"] = ref
|
||||
|
||||
if "Line" in qbo_data:
|
||||
for line in qbo_data.get("Line", []):
|
||||
detail = line.get("SalesItemLineDetail", {})
|
||||
if "ItemRef" in detail:
|
||||
item = detail["ItemRef"].get("value") if isinstance(detail["ItemRef"], dict) else detail["ItemRef"]
|
||||
if item and not str(item).isdigit():
|
||||
ref, err = self.resolver.resolve_item(str(item))
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
detail["ItemRef"] = ref
|
||||
|
||||
elif data_type == "bill":
|
||||
if "VendorRef" in qbo_data:
|
||||
vendor_name = qbo_data["VendorRef"].get("value") if isinstance(qbo_data["VendorRef"], dict) else qbo_data["VendorRef"]
|
||||
if vendor_name and not str(vendor_name).isdigit():
|
||||
ref, err = self.resolver.resolve_vendor(str(vendor_name))
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
qbo_data["VendorRef"] = ref
|
||||
|
||||
if "Line" in qbo_data:
|
||||
for line in qbo_data.get("Line", []):
|
||||
detail = line.get("AccountBasedExpenseLineDetail", {})
|
||||
if "AccountRef" in detail:
|
||||
acct = detail["AccountRef"].get("value") if isinstance(detail["AccountRef"], dict) else detail["AccountRef"]
|
||||
if acct and not str(acct).isdigit():
|
||||
ref, err = self.resolver.resolve_account(str(acct))
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
detail["AccountRef"] = ref
|
||||
|
||||
return "; ".join(errors) if errors else None
|
||||
|
||||
def import_records(
|
||||
self,
|
||||
records: List[ImportRecord],
|
||||
data_type: str
|
||||
) -> ImportResult:
|
||||
"""Import validated records to QuickBooks Online."""
|
||||
start_time = datetime.now()
|
||||
|
||||
result = ImportResult(
|
||||
data_type=data_type,
|
||||
total_records=len(records),
|
||||
successful=0,
|
||||
failed=0,
|
||||
skipped=0,
|
||||
duplicates=0,
|
||||
records=records,
|
||||
start_time=start_time.isoformat()
|
||||
)
|
||||
|
||||
# Count pre-existing failures
|
||||
for record in records:
|
||||
if record.status == ImportStatus.FAILED:
|
||||
result.failed += 1
|
||||
self._log("error", f"Row {record.row_number}: Pre-validation failed - {record.error_message}")
|
||||
elif record.status == ImportStatus.DUPLICATE:
|
||||
result.duplicates += 1
|
||||
self._log("warning", f"Row {record.row_number}: Skipped (duplicate)")
|
||||
elif record.status == ImportStatus.SKIPPED:
|
||||
result.skipped += 1
|
||||
self._log("warning", f"Row {record.row_number}: Skipped - {record.error_message or 'Unknown reason'}")
|
||||
|
||||
pending_records = [r for r in records if r.status == ImportStatus.PENDING]
|
||||
|
||||
self._log("info", f"Importing {len(pending_records)} records to QuickBooks Online...")
|
||||
|
||||
# Process in batches
|
||||
batch_size = self.settings.batch_size
|
||||
for batch_start in range(0, len(pending_records), batch_size):
|
||||
batch = pending_records[batch_start:batch_start + batch_size]
|
||||
batch_num = (batch_start // batch_size) + 1
|
||||
total_batches = (len(pending_records) + batch_size - 1) // batch_size
|
||||
|
||||
self._log("info", f"Processing batch {batch_num}/{total_batches} ({len(batch)} records)")
|
||||
|
||||
for i, record in enumerate(batch):
|
||||
current = batch_start + i + 1
|
||||
self._progress(current, len(pending_records), f"Importing record {current}")
|
||||
|
||||
record.status = ImportStatus.PROCESSING
|
||||
record.timestamp = datetime.now().isoformat()
|
||||
|
||||
try:
|
||||
# Log the data being sent
|
||||
self._log("debug", f"Row {record.row_number}: Sending data - {json.dumps(record.qbo_data, default=str)[:500]}")
|
||||
|
||||
if data_type == "check":
|
||||
check_data = record.qbo_data.copy()
|
||||
check_data["PaymentType"] = "Check"
|
||||
qbo_entity = self.client.create_check(check_data)
|
||||
elif data_type == "invoice":
|
||||
qbo_entity = self.client.create_invoice(record.qbo_data)
|
||||
elif data_type == "bill":
|
||||
qbo_entity = self.client.create_bill(record.qbo_data)
|
||||
elif data_type == "customer":
|
||||
qbo_entity = self.client.create_customer(record.qbo_data)
|
||||
elif data_type == "vendor":
|
||||
qbo_entity = self.client.create_vendor(record.qbo_data)
|
||||
elif data_type == "account":
|
||||
qbo_entity = self.client.create_account(record.qbo_data)
|
||||
else:
|
||||
raise ValueError(f"Unknown data type: {data_type}")
|
||||
|
||||
record.status = ImportStatus.SUCCESS
|
||||
record.qbo_id = qbo_entity.get("Id")
|
||||
result.successful += 1
|
||||
|
||||
self.duplicate_checker.add_imported(data_type, record.qbo_data)
|
||||
|
||||
self._log("info", f"Row {record.row_number}: SUCCESS - Created {data_type} ID {record.qbo_id}")
|
||||
|
||||
except Exception as e:
|
||||
record.status = ImportStatus.FAILED
|
||||
error_str = str(e)
|
||||
record.error_message = error_str
|
||||
result.failed += 1
|
||||
|
||||
# Extract key data for error context
|
||||
key_fields = self._get_key_fields(record.qbo_data, data_type)
|
||||
|
||||
self._log("error", f"Row {record.row_number}: FAILED - {error_str}")
|
||||
self._log("error", f"Row {record.row_number}: Key fields - {key_fields}")
|
||||
|
||||
# Log original Excel data for debugging
|
||||
if record.original_data:
|
||||
orig_data_str = ", ".join(f"{k}={v}" for k, v in record.original_data.items() if v)
|
||||
self._log("error", f"Row {record.row_number}: Original data - {orig_data_str[:300]}")
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
end_time = datetime.now()
|
||||
result.end_time = end_time.isoformat()
|
||||
result.duration_seconds = (end_time - start_time).total_seconds()
|
||||
|
||||
self._log("info", "=" * 50)
|
||||
self._log("info", f"Import complete: {result.successful} successful, {result.failed} failed, "
|
||||
f"{result.duplicates} duplicates, {result.skipped} skipped")
|
||||
self._log("info", f"Duration: {result.duration_seconds:.2f} seconds")
|
||||
self._log("info", f"Success rate: {result.success_rate:.1f}%")
|
||||
|
||||
return result
|
||||
|
||||
def _get_key_fields(self, qbo_data: Dict[str, Any], data_type: str) -> str:
|
||||
"""Extract key fields from QBO data for error context."""
|
||||
key_info = []
|
||||
|
||||
if data_type == "check":
|
||||
if "EntityRef" in qbo_data:
|
||||
entity = qbo_data["EntityRef"]
|
||||
key_info.append(f"Payee: {entity.get('name') or entity.get('value', 'N/A')}")
|
||||
if "AccountRef" in qbo_data:
|
||||
acct = qbo_data["AccountRef"]
|
||||
key_info.append(f"Account: {acct.get('name') or acct.get('value', 'N/A')}")
|
||||
if "TxnDate" in qbo_data:
|
||||
key_info.append(f"Date: {qbo_data['TxnDate']}")
|
||||
if "DocNumber" in qbo_data:
|
||||
key_info.append(f"DocNumber: {qbo_data['DocNumber']}")
|
||||
if "Line" in qbo_data and qbo_data["Line"]:
|
||||
total = sum(line.get("Amount", 0) for line in qbo_data["Line"])
|
||||
key_info.append(f"Amount: ${total:,.2f}")
|
||||
|
||||
elif data_type == "invoice":
|
||||
if "CustomerRef" in qbo_data:
|
||||
cust = qbo_data["CustomerRef"]
|
||||
key_info.append(f"Customer: {cust.get('name') or cust.get('value', 'N/A')}")
|
||||
if "TxnDate" in qbo_data:
|
||||
key_info.append(f"Date: {qbo_data['TxnDate']}")
|
||||
if "DocNumber" in qbo_data:
|
||||
key_info.append(f"Invoice#: {qbo_data['DocNumber']}")
|
||||
|
||||
elif data_type == "bill":
|
||||
if "VendorRef" in qbo_data:
|
||||
vendor = qbo_data["VendorRef"]
|
||||
key_info.append(f"Vendor: {vendor.get('name') or vendor.get('value', 'N/A')}")
|
||||
if "TxnDate" in qbo_data:
|
||||
key_info.append(f"Date: {qbo_data['TxnDate']}")
|
||||
if "DocNumber" in qbo_data:
|
||||
key_info.append(f"Bill#: {qbo_data['DocNumber']}")
|
||||
|
||||
elif data_type in ("customer", "vendor"):
|
||||
if "DisplayName" in qbo_data:
|
||||
key_info.append(f"Name: {qbo_data['DisplayName']}")
|
||||
if "CompanyName" in qbo_data:
|
||||
key_info.append(f"Company: {qbo_data['CompanyName']}")
|
||||
|
||||
elif data_type == "account":
|
||||
if "Name" in qbo_data:
|
||||
key_info.append(f"Name: {qbo_data['Name']}")
|
||||
if "AccountType" in qbo_data:
|
||||
key_info.append(f"Type: {qbo_data['AccountType']}")
|
||||
|
||||
return " | ".join(key_info) if key_info else "No key fields"
|
||||
|
||||
def process(
|
||||
self,
|
||||
parse_result: ParseResult,
|
||||
data_type: str
|
||||
) -> ImportResult:
|
||||
"""Full import process: validate, resolve, and import."""
|
||||
records = self.validate_and_resolve(parse_result, data_type)
|
||||
|
||||
pending = sum(1 for r in records if r.status == ImportStatus.PENDING)
|
||||
failed = sum(1 for r in records if r.status == ImportStatus.FAILED)
|
||||
duplicates = sum(1 for r in records if r.status == ImportStatus.DUPLICATE)
|
||||
|
||||
self._log("info", f"Validation complete: {pending} ready, {failed} failed, {duplicates} duplicates")
|
||||
|
||||
if pending == 0:
|
||||
self._log("warning", "No records to import after validation")
|
||||
return ImportResult(
|
||||
data_type=data_type,
|
||||
total_records=len(records),
|
||||
successful=0,
|
||||
failed=failed,
|
||||
skipped=0,
|
||||
duplicates=duplicates,
|
||||
records=records,
|
||||
start_time=datetime.now().isoformat(),
|
||||
end_time=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
return self.import_records(records, data_type)
|
||||
@@ -0,0 +1,234 @@
|
||||
/* QBO Excel Sync - Modern Web Interface Styles */
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--secondary: #64748b;
|
||||
--success: #059669;
|
||||
--success-light: #d1fae5;
|
||||
--warning: #d97706;
|
||||
--warning-light: #fef3c7;
|
||||
--danger: #dc2626;
|
||||
--danger-light: #fee2e2;
|
||||
--bg: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
|
||||
--shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--font: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; min-height: 100vh; }
|
||||
|
||||
/* Header */
|
||||
.main-header { background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%); color: white; padding: 16px 24px; }
|
||||
.header-content { max-width: 1400px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
|
||||
.logo-section { display: flex; align-items: center; gap: 16px; }
|
||||
.title-group h1 { font-size: 20px; font-weight: 700; }
|
||||
.title-group .subtitle { font-size: 13px; opacity: 0.8; }
|
||||
.header-status { display: flex; align-items: center; gap: 12px; }
|
||||
.connection-status { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: rgba(255,255,255,0.15); border-radius: 20px; font-size: 13px; }
|
||||
.connection-status.connected { background: rgba(5,150,105,0.3); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.environment-badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
|
||||
.environment-badge.sandbox { background: var(--warning); }
|
||||
.environment-badge.production { background: var(--success); }
|
||||
|
||||
/* Navigation */
|
||||
.main-nav { background: var(--bg-card); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; }
|
||||
.nav-content { max-width: 1400px; margin: 0 auto; display: flex; gap: 4px; padding: 0 24px; }
|
||||
.nav-link { display: flex; align-items: center; gap: 8px; padding: 16px 20px; color: var(--text-muted); text-decoration: none; font-weight: 500; font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
||||
.nav-link:hover { color: var(--text); background: var(--bg); }
|
||||
.nav-link.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
|
||||
/* Main Content */
|
||||
.main-content { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h2 { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
|
||||
.page-header p { color: var(--text-muted); font-size: 15px; }
|
||||
|
||||
/* Cards */
|
||||
.card { background: var(--bg-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); border: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-header h3 { font-size: 16px; font-weight: 600; }
|
||||
.card-body { padding: 20px; }
|
||||
|
||||
/* Forms */
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label { font-size: 14px; font-weight: 500; }
|
||||
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||||
input[type="text"], input[type="password"], input[type="number"], textarea, select { padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 14px; font-family: var(--font); transition: border-color 0.2s; }
|
||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
|
||||
small { font-size: 12px; color: var(--text-muted); }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.checkbox-label input { width: 18px; height: 18px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; border: none; border-radius: var(--radius); font-size: 14px; font-weight: 600; font-family: var(--font); cursor: pointer; transition: all 0.2s; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||
.btn-secondary { background: var(--bg); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--border); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
.btn-icon { padding: 8px; }
|
||||
.button-group { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.actions-row { display: flex; justify-content: flex-end; gap: 12px; }
|
||||
|
||||
/* Tables */
|
||||
.table-container { overflow-x: auto; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th, .data-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
.data-table th { background: var(--bg); font-weight: 600; color: var(--text-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.data-table tbody tr:hover { background: var(--bg); }
|
||||
.data-table input, .data-table select { width: 100%; padding: 6px 10px; font-size: 13px; }
|
||||
.data-table tr.error td { background: var(--danger-light); }
|
||||
.data-table tr.warning td { background: var(--warning-light); }
|
||||
|
||||
/* Tabs */
|
||||
.tabs-header { display: flex; border-bottom: 1px solid var(--border); padding: 0 20px; }
|
||||
.tab-btn { padding: 12px 20px; background: none; border: none; border-bottom: 2px solid transparent; font-size: 14px; font-weight: 500; color: var(--text-muted); cursor: pointer; }
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.tab-content { display: none; padding: 20px; }
|
||||
.tab-content.active { display: block; }
|
||||
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
|
||||
/* File Upload */
|
||||
.file-upload-area { border: 2px dashed var(--border); border-radius: var(--radius-lg); padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s; }
|
||||
.file-upload-area:hover, .file-upload-area.dragover { border-color: var(--primary); background: rgba(37,99,235,0.05); }
|
||||
.upload-link { color: var(--primary); font-weight: 500; }
|
||||
.file-info { display: flex; align-items: center; gap: 16px; padding: 16px; background: var(--bg); border-radius: var(--radius); margin-top: 16px; }
|
||||
.file-details { flex: 1; }
|
||||
.file-name { font-weight: 600; display: block; }
|
||||
.file-meta { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* Progress */
|
||||
.progress-bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary) 0%, #7c3aed 100%); border-radius: 4px; width: 50%; animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
||||
.progress-section { margin-bottom: 20px; }
|
||||
.progress-text { font-size: 13px; color: var(--text-muted); display: block; margin-top: 8px; }
|
||||
|
||||
/* Validation */
|
||||
.validation-summary { margin-bottom: 20px; }
|
||||
.validation-stats { display: flex; gap: 24px; padding: 16px; background: var(--bg); border-radius: var(--radius); }
|
||||
|
||||
/* Log Output */
|
||||
.log-output { background: #1e293b; color: #e2e8f0; font-family: var(--font-mono); font-size: 12px; padding: 16px; border-radius: var(--radius); max-height: 200px; overflow-y: auto; }
|
||||
.log-output .log-info { color: #60a5fa; }
|
||||
.log-output .log-warning { color: #fbbf24; }
|
||||
.log-output .log-error { color: #f87171; }
|
||||
|
||||
/* Alerts */
|
||||
.alert { display: flex; align-items: flex-start; gap: 12px; padding: 16px; border-radius: var(--radius); margin-bottom: 20px; }
|
||||
.alert-warning { background: var(--warning-light); color: #92400e; }
|
||||
.alert a { color: inherit; font-weight: 600; }
|
||||
|
||||
/* Dashboard */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 32px; }
|
||||
.stat-card { background: var(--bg-card); border-radius: var(--radius-lg); padding: 24px; display: flex; gap: 16px; border: 1px solid var(--border); }
|
||||
.stat-card.connected .stat-icon { color: var(--success); background: var(--success-light); }
|
||||
.stat-card.disconnected .stat-icon { color: var(--danger); background: var(--danger-light); }
|
||||
.stat-icon { width: 48px; height: 48px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; background: var(--bg); }
|
||||
.stat-content h3 { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; }
|
||||
.section { margin-bottom: 32px; }
|
||||
.section-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
|
||||
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
|
||||
.action-card { display: flex; align-items: center; gap: 16px; padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); text-decoration: none; color: var(--text); transition: all 0.2s; }
|
||||
.action-card:hover { border-color: var(--primary); box-shadow: var(--shadow); }
|
||||
.action-card.primary { background: linear-gradient(135deg, var(--primary) 0%, #7c3aed 100%); color: white; border: none; }
|
||||
.action-card.primary:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
||||
.action-icon { width: 48px; height: 48px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.2); }
|
||||
.action-content { flex: 1; }
|
||||
.action-content h4 { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
||||
.action-content p { font-size: 13px; opacity: 0.8; }
|
||||
.data-types-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
||||
.data-type-card { padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); text-align: center; }
|
||||
.data-type-icon { width: 48px; height: 48px; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; }
|
||||
.data-type-icon.check { background: #dbeafe; color: #2563eb; }
|
||||
.data-type-icon.invoice { background: #d1fae5; color: #059669; }
|
||||
.data-type-icon.bill { background: #fef3c7; color: #d97706; }
|
||||
.data-type-icon.customer { background: #ede9fe; color: #7c3aed; }
|
||||
.data-type-icon.vendor { background: #fce7f3; color: #db2777; }
|
||||
.data-type-icon.account { background: #cffafe; color: #0891b2; }
|
||||
.data-type-card h4 { font-size: 15px; margin-bottom: 8px; }
|
||||
.data-type-card p { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Connection Page */
|
||||
.connection-layout { display: grid; gap: 20px; }
|
||||
.connection-status-display { text-align: center; padding: 32px; }
|
||||
.connection-status-display.connected .status-icon { color: var(--success); }
|
||||
.connection-status-display.disconnected .status-icon { color: var(--danger); }
|
||||
.connection-status-display h4 { font-size: 20px; margin-top: 16px; }
|
||||
.company-name { color: var(--text-muted); }
|
||||
.connection-actions { display: flex; justify-content: center; gap: 12px; margin-top: 20px; }
|
||||
.help-section { margin-bottom: 20px; }
|
||||
.help-section h4 { font-size: 14px; margin-bottom: 8px; }
|
||||
.help-section ol { padding-left: 20px; }
|
||||
.help-section li { margin-bottom: 6px; font-size: 13px; }
|
||||
.help-section code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); }
|
||||
.help-note { background: #eff6ff; padding: 12px 16px; border-radius: var(--radius); font-size: 13px; color: #1e40af; }
|
||||
|
||||
/* Templates Page */
|
||||
.templates-layout { display: grid; grid-template-columns: 280px 1fr; gap: 24px; }
|
||||
.templates-sidebar { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border); overflow: hidden; }
|
||||
.sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.filter-group { padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||
.filter-group select { width: 100%; }
|
||||
.template-list { list-style: none; }
|
||||
.template-item { padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.2s; }
|
||||
.template-item:hover { background: var(--bg); }
|
||||
.template-item.active { background: rgba(37,99,235,0.1); border-left: 3px solid var(--primary); }
|
||||
.template-name { display: block; font-weight: 500; }
|
||||
.template-type { font-size: 12px; color: var(--text-muted); }
|
||||
.template-detail { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border); padding: 24px; }
|
||||
.empty-detail, .empty-state { text-align: center; padding: 40px; color: var(--text-muted); }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.detail-actions { display: flex; gap: 12px; }
|
||||
.badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; background: var(--bg); color: var(--text-muted); }
|
||||
|
||||
/* Settings Page */
|
||||
.settings-layout { max-width: 800px; }
|
||||
.settings-actions { margin-top: 24px; }
|
||||
|
||||
/* Modal */
|
||||
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
||||
.modal-content { position: relative; background: var(--bg-card); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); width: 90%; max-width: 800px; max-height: 90vh; overflow: auto; }
|
||||
.modal-header { padding: 20px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { font-size: 18px; }
|
||||
.modal-body { padding: 24px; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 12px; }
|
||||
|
||||
/* Toast */
|
||||
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 12px; }
|
||||
.toast { display: flex; align-items: center; gap: 12px; padding: 14px 20px; background: var(--bg-card); border-radius: var(--radius); box-shadow: var(--shadow-lg); border-left: 4px solid var(--border); animation: toast-in 0.3s ease; max-width: 400px; }
|
||||
.toast.success { border-left-color: var(--success); }
|
||||
.toast.warning { border-left-color: var(--warning); }
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
.toast-message { flex: 1; font-size: 14px; }
|
||||
.toast-close { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; }
|
||||
@keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
/* Config Row */
|
||||
.config-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-content { flex-direction: column; gap: 16px; }
|
||||
.templates-layout { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.action-grid { grid-template-columns: 1fr; }
|
||||
.nav-content { overflow-x: auto; }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* QBO Excel Sync - Main JavaScript */
|
||||
|
||||
// Toast notification system
|
||||
function showToast(message, type = 'info', duration = 4000) {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span class="toast-message">${escapeHtml(message)}</span>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'toast-out 0.3s ease forwards';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// HTML escape utility
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Format currency for display
|
||||
function formatCurrency(amount, currency = 'USD') {
|
||||
if (amount === null || amount === undefined) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Debounce function for search inputs
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// API request helper with error handling
|
||||
async function apiRequest(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...defaultOptions, ...options });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API request error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm dialog helper
|
||||
function confirmAction(message) {
|
||||
return confirm(message);
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
function setLoading(element, isLoading, loadingText = 'Loading...') {
|
||||
if (isLoading) {
|
||||
element.dataset.originalText = element.textContent;
|
||||
element.textContent = loadingText;
|
||||
element.disabled = true;
|
||||
} else {
|
||||
element.textContent = element.dataset.originalText || element.textContent;
|
||||
element.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tooltips (basic implementation)
|
||||
function initTooltips() {
|
||||
document.querySelectorAll('[data-tooltip]').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = el.dataset.tooltip;
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
|
||||
tooltip.style.left = `${rect.left + (rect.width - tooltip.offsetWidth) / 2}px`;
|
||||
|
||||
el._tooltip = tooltip;
|
||||
});
|
||||
|
||||
el.addEventListener('mouseleave', () => {
|
||||
if (el._tooltip) {
|
||||
el._tooltip.remove();
|
||||
delete el._tooltip;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('Copied to clipboard', 'success');
|
||||
} catch (err) {
|
||||
showToast('Failed to copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// File size formatter
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Check connection status
|
||||
async function checkConnectionStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/connection/test');
|
||||
const data = await response.json();
|
||||
return data.connected === true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection indicator
|
||||
function updateConnectionIndicator(isConnected) {
|
||||
const indicator = document.querySelector('.connection-status');
|
||||
if (indicator) {
|
||||
indicator.className = `connection-status ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
indicator.querySelector('span:last-child').textContent = isConnected ? 'Connected' : 'Not Connected';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTooltips();
|
||||
|
||||
// Add active class to current nav item
|
||||
const currentPath = window.location.pathname;
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
if (link.getAttribute('href') === currentPath) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.QBO = {
|
||||
showToast,
|
||||
escapeHtml,
|
||||
formatDate,
|
||||
formatCurrency,
|
||||
debounce,
|
||||
apiRequest,
|
||||
confirmAction,
|
||||
setLoading,
|
||||
copyToClipboard,
|
||||
formatFileSize,
|
||||
checkConnectionStatus,
|
||||
updateConnectionIndicator
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Page Not Found - QBO Excel Sync{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<div class="error-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.error-page { display: flex; align-items: center; justify-content: center; min-height: 60vh; }
|
||||
.error-content { text-align: center; }
|
||||
.error-icon { color: #64748b; margin-bottom: 24px; }
|
||||
.error-page h1 { font-size: 72px; font-weight: 800; color: #e2e8f0; margin: 0; }
|
||||
.error-page h2 { font-size: 24px; margin: 8px 0 16px; }
|
||||
.error-page p { color: #64748b; margin-bottom: 24px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Server Error - QBO Excel Sync{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<div class="error-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>500</h1>
|
||||
<h2>Server Error</h2>
|
||||
<p>Something went wrong on our end. Please try again later.</p>
|
||||
<div class="button-group" style="justify-content: center;">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Back to Dashboard</a>
|
||||
<button onclick="location.reload()" class="btn btn-secondary">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.error-page { display: flex; align-items: center; justify-content: center; min-height: 60vh; }
|
||||
.error-content { text-align: center; }
|
||||
.error-icon { color: #dc2626; margin-bottom: 24px; }
|
||||
.error-page h1 { font-size: 72px; font-weight: 800; color: #fee2e2; margin: 0; }
|
||||
.error-page h2 { font-size: 24px; margin: 8px 0 16px; }
|
||||
.error-page p { color: #64748b; margin-bottom: 24px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}QBO Excel Sync{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#gradient)"/>
|
||||
<path d="M8 12H24M8 16H20M8 20H16" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32">
|
||||
<stop offset="0%" stop-color="#2563eb"/>
|
||||
<stop offset="100%" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="title-group">
|
||||
<h1>QBO Excel Sync</h1>
|
||||
<span class="subtitle">Import Excel data to QuickBooks Online</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="connection-status {% if is_connected %}connected{% else %}disconnected{% endif %}">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">
|
||||
{% if is_connected %}
|
||||
Connected: {{ company_name or 'Unknown' }}
|
||||
{% else %}
|
||||
Not Connected
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="environment-badge {{ environment }}">
|
||||
{{ environment|upper }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="main-nav">
|
||||
<div class="nav-content">
|
||||
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('connection') }}" class="nav-link {% if request.endpoint == 'connection' %}active{% endif %}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
Connection
|
||||
</a>
|
||||
<a href="{{ url_for('import_page') }}" class="nav-link {% if request.endpoint == 'import_page' %}active{% endif %}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import
|
||||
</a>
|
||||
<a href="{{ url_for('templates_page') }}" class="nav-link {% if request.endpoint == 'templates_page' %}active{% endif %}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
Templates
|
||||
</a>
|
||||
<a href="{{ url_for('settings_page') }}" class="nav-link {% if request.endpoint == 'settings_page' %}active{% endif %}">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,508 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Connection - QBO Excel Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="connection-page">
|
||||
<div class="page-header">
|
||||
<h2>QuickBooks Connection</h2>
|
||||
<p>Connect to your QuickBooks Online account to start importing data.</p>
|
||||
</div>
|
||||
|
||||
<div class="connection-layout">
|
||||
<!-- Credentials Section -->
|
||||
<section class="card collapsible">
|
||||
<div class="card-header" onclick="toggleSection(this)">
|
||||
<h3>API Credentials</h3>
|
||||
<span class="collapse-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body collapsible-content">
|
||||
<form id="credentials-form" class="form">
|
||||
<div class="form-group">
|
||||
<label for="client_id">Client ID</label>
|
||||
<input type="text" id="client_id" name="client_id"
|
||||
value="{{ credentials.client_id }}"
|
||||
placeholder="Enter your Intuit Developer Client ID">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="client_secret">Client Secret</label>
|
||||
<input type="password" id="client_secret" name="client_secret"
|
||||
value="{{ credentials.client_secret }}"
|
||||
placeholder="Enter your Client Secret">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="redirect_uri">Redirect URI</label>
|
||||
<input type="text" id="redirect_uri" name="redirect_uri"
|
||||
value="{{ credentials.redirect_uri or 'http://localhost:5000/callback' }}"
|
||||
placeholder="http://localhost:5000/callback">
|
||||
<small class="form-help">This must match exactly in your Intuit Developer app settings.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="environment">Environment</label>
|
||||
<select id="environment" name="environment">
|
||||
<option value="sandbox" {% if environment == 'sandbox' %}selected{% endif %}>Sandbox (Testing)</option>
|
||||
<option value="production" {% if environment == 'production' %}selected{% endif %}>Production (Live)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
Save Credentials
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connection Status Section -->
|
||||
<section class="card collapsible">
|
||||
<div class="card-header" onclick="toggleSection(this)">
|
||||
<h3>Connection Status</h3>
|
||||
<span class="collapse-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body collapsible-content">
|
||||
<div class="connection-status-display {% if is_connected %}connected{% else %}disconnected{% endif %}">
|
||||
<div class="status-icon">
|
||||
{% if is_connected %}
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="status-details">
|
||||
<h4>{% if is_connected %}Connected{% else %}Not Connected{% endif %}</h4>
|
||||
{% if is_connected and company_name %}
|
||||
<p class="company-name">{{ company_name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-actions">
|
||||
{% if is_connected %}
|
||||
<button type="button" id="test-connection" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="button" id="disconnect" class="btn btn-danger">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
|
||||
<line x1="12" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
Disconnect
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" id="connect-oauth" class="btn btn-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
Connect to QuickBooks
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Manual Token Entry -->
|
||||
<section class="card collapsible collapsed">
|
||||
<div class="card-header" onclick="toggleSection(this)">
|
||||
<h3>Alternative: Manual Token Entry</h3>
|
||||
<div class="header-right">
|
||||
<span class="card-badge">Advanced</span>
|
||||
<span class="collapse-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body collapsible-content">
|
||||
<p class="card-description">
|
||||
If OAuth redirect isn't working, you can manually enter tokens from Intuit's OAuth 2.0 Playground.
|
||||
</p>
|
||||
|
||||
<form id="manual-token-form" class="form">
|
||||
<div class="form-group">
|
||||
<label for="access_token">Access Token</label>
|
||||
<textarea id="access_token" name="access_token" rows="3"
|
||||
placeholder="Paste access token from OAuth Playground"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="refresh_token">Refresh Token</label>
|
||||
<textarea id="refresh_token" name="refresh_token" rows="2"
|
||||
placeholder="Paste refresh token (optional but recommended)"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="realm_id">Realm ID (Company ID)</label>
|
||||
<input type="text" id="realm_id" name="realm_id"
|
||||
placeholder="Enter the Realm ID / Company ID">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
Save Tokens & Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Help Section -->
|
||||
<section class="card help-card collapsible collapsed">
|
||||
<div class="card-header" onclick="toggleSection(this)">
|
||||
<h3>Setup Instructions</h3>
|
||||
<span class="collapse-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body collapsible-content">
|
||||
<div class="help-section">
|
||||
<h4>Option 1: OAuth Flow (Recommended)</h4>
|
||||
<ol>
|
||||
<li>Go to <a href="https://developer.intuit.com" target="_blank">developer.intuit.com</a></li>
|
||||
<li>Create or select your app → "Keys & OAuth" section</li>
|
||||
<li>Add redirect URI: <code>http://localhost:5000/callback</code></li>
|
||||
<li>Copy your Client ID and Client Secret</li>
|
||||
<li>Save credentials above, then click "Connect to QuickBooks"</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Option 2: Manual Token Entry</h4>
|
||||
<ol>
|
||||
<li>In the developer portal, click "OAuth 2.0 Playground"</li>
|
||||
<li>Authorize your sandbox/production company</li>
|
||||
<li>Copy the Access Token, Refresh Token, and Realm ID</li>
|
||||
<li>Paste them in the "Manual Token Entry" section above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="help-note">
|
||||
<strong>Note:</strong> Access tokens expire after 1 hour. The app will automatically refresh using the refresh token.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Collapsible Sections */
|
||||
.card.collapsible .card-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.collapsible .card-header:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card.collapsible.collapsed .collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card.collapsible.collapsed .collapsible-content {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Ensure card-body padding transitions smoothly */
|
||||
.card.collapsible .card-body {
|
||||
transition: padding 0.3s ease;
|
||||
}
|
||||
|
||||
.card.collapsible.collapsed .card-body {
|
||||
padding: 0 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Toggle collapsible section
|
||||
function toggleSection(header) {
|
||||
const card = header.closest('.card.collapsible');
|
||||
card.classList.toggle('collapsed');
|
||||
|
||||
// Save state to localStorage
|
||||
const sectionId = header.querySelector('h3').textContent.trim();
|
||||
const isCollapsed = card.classList.contains('collapsed');
|
||||
localStorage.setItem('section_' + sectionId, isCollapsed ? 'collapsed' : 'expanded');
|
||||
}
|
||||
|
||||
// Global variable to track OAuth polling
|
||||
let oauthPollTimer = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if we need to handle OAuth completion on page load
|
||||
const oauthComplete = localStorage.getItem('oauth_complete');
|
||||
if (oauthComplete) {
|
||||
localStorage.removeItem('oauth_complete');
|
||||
// Page was just reloaded after OAuth - show success message
|
||||
showToast('Successfully connected to QuickBooks!', 'success');
|
||||
}
|
||||
|
||||
// Restore section states from localStorage
|
||||
document.querySelectorAll('.card.collapsible').forEach(card => {
|
||||
const sectionId = card.querySelector('.card-header h3').textContent.trim();
|
||||
const savedState = localStorage.getItem('section_' + sectionId);
|
||||
|
||||
if (savedState === 'collapsed') {
|
||||
card.classList.add('collapsed');
|
||||
} else if (savedState === 'expanded') {
|
||||
card.classList.remove('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// Save credentials form
|
||||
document.getElementById('credentials-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
client_id: document.getElementById('client_id').value,
|
||||
client_secret: document.getElementById('client_secret').value,
|
||||
redirect_uri: document.getElementById('redirect_uri').value,
|
||||
environment: document.getElementById('environment').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Credentials saved successfully', 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to save credentials', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error saving credentials: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Connect via OAuth
|
||||
const connectBtn = document.getElementById('connect-oauth');
|
||||
if (connectBtn) {
|
||||
connectBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
const response = await fetch('/api/oauth/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.auth_url) {
|
||||
// Clear any previous OAuth flag
|
||||
localStorage.removeItem('oauth_complete');
|
||||
|
||||
// Open OAuth in new window
|
||||
const oauthPopup = window.open(result.auth_url, 'QBO_OAuth', 'width=600,height=700');
|
||||
|
||||
// Start polling for OAuth completion
|
||||
startOAuthPolling(oauthPopup);
|
||||
} else {
|
||||
showToast(result.error || 'Failed to start OAuth flow', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error starting OAuth: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection
|
||||
const testBtn = document.getElementById('test-connection');
|
||||
if (testBtn) {
|
||||
testBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
const response = await fetch('/api/connection/test');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(`Connected to ${result.company_name}. Found ${result.customers_count} customers, ${result.vendors_count} vendors, ${result.accounts_count} accounts.`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Connection test failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error testing connection: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
const disconnectBtn = document.getElementById('disconnect');
|
||||
if (disconnectBtn) {
|
||||
disconnectBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Are you sure you want to disconnect from QuickBooks?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/disconnect', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Disconnected successfully', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(result.error || 'Failed to disconnect', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error disconnecting: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Manual token form
|
||||
document.getElementById('manual-token-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
access_token: document.getElementById('access_token').value,
|
||||
refresh_token: document.getElementById('refresh_token').value,
|
||||
realm_id: document.getElementById('realm_id').value
|
||||
};
|
||||
|
||||
if (!formData.access_token || !formData.realm_id) {
|
||||
showToast('Please enter at least the Access Token and Realm ID', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/manual', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast(`Connected to ${result.company_name}`, 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(result.error || 'Failed to connect with tokens', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error connecting: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Function to poll for OAuth completion
|
||||
function startOAuthPolling(popup) {
|
||||
// Clear any existing timer
|
||||
if (oauthPollTimer) {
|
||||
clearInterval(oauthPollTimer);
|
||||
}
|
||||
|
||||
let checkCount = 0;
|
||||
const maxChecks = 300; // 5 minutes max (300 * 1000ms)
|
||||
|
||||
oauthPollTimer = setInterval(async function() {
|
||||
checkCount++;
|
||||
|
||||
// Check localStorage for OAuth completion signal
|
||||
const oauthComplete = localStorage.getItem('oauth_complete');
|
||||
if (oauthComplete) {
|
||||
clearInterval(oauthPollTimer);
|
||||
localStorage.removeItem('oauth_complete');
|
||||
showToast('Successfully connected to QuickBooks!', 'success');
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if popup is closed
|
||||
if (popup && popup.closed) {
|
||||
clearInterval(oauthPollTimer);
|
||||
|
||||
// Wait a moment then check connection status
|
||||
setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/connection/test');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.connected === true) {
|
||||
showToast('Successfully connected to QuickBooks!', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
|
||||
}
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop polling after max time
|
||||
if (checkCount >= maxChecks) {
|
||||
clearInterval(oauthPollTimer);
|
||||
showToast('OAuth timeout. Please try again.', 'warning');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - QBO Excel Sync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
<p>Welcome to QBO Excel Sync. Import your Excel data to QuickBooks Online with ease.</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card {% if is_connected %}connected{% else %}disconnected{% endif %}">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>Connection Status</h3>
|
||||
<p class="stat-value">{% if is_connected %}Connected{% else %}Not Connected{% endif %}</p>
|
||||
{% if is_connected and company_name %}
|
||||
<p class="stat-detail">{{ company_name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<line x1="10" y1="9" x2="8" y2="9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>Environment</h3>
|
||||
<p class="stat-value">{{ environment|title }}</p>
|
||||
<p class="stat-detail">{% if environment == 'sandbox' %}Test mode{% else %}Live data{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>Supported Types</h3>
|
||||
<p class="stat-value">6 Types</p>
|
||||
<p class="stat-detail">Checks, Invoices, Bills, & more</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Quick Actions</h3>
|
||||
<div class="action-grid">
|
||||
{% if not is_connected %}
|
||||
<a href="{{ url_for('connection') }}" class="action-card primary">
|
||||
<div class="action-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4>Connect to QuickBooks</h4>
|
||||
<p>Set up your QuickBooks Online connection to start importing data.</p>
|
||||
</div>
|
||||
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('import_page') }}" class="action-card primary">
|
||||
<div class="action-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4>Import Excel File</h4>
|
||||
<p>Upload and import an Excel file to QuickBooks Online.</p>
|
||||
</div>
|
||||
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('templates_page') }}" class="action-card">
|
||||
<div class="action-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4>Manage Templates</h4>
|
||||
<p>Create and edit field mapping templates for your imports.</p>
|
||||
</div>
|
||||
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('settings_page') }}" class="action-card">
|
||||
<div class="action-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4>Configure Settings</h4>
|
||||
<p>Customize import behavior, date formats, and more.</p>
|
||||
</div>
|
||||
<svg class="action-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% if success %}Authorization Successful{% else %}Authorization Failed{% endif %} - QBO Excel Sync</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
.icon.success { background: #d1fae5; color: #059669; }
|
||||
.icon.error { background: #fee2e2; color: #dc2626; }
|
||||
.icon svg { width: 40px; height: 40px; }
|
||||
h1 { font-size: 24px; margin-bottom: 12px; color: #1f2937; }
|
||||
p { color: #6b7280; margin-bottom: 24px; line-height: 1.6; }
|
||||
.error-msg { background: #fef2f2; color: #dc2626; padding: 12px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 24px; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px -5px rgba(37, 99, 235, 0.4); }
|
||||
.auto-close { font-size: 12px; color: #9ca3af; margin-top: 24px; }
|
||||
.countdown { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% if success %}
|
||||
<div class="icon success">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You have successfully connected to QuickBooks Online.</p>
|
||||
<button type="button" class="btn" onclick="closeAndRefresh()">Close & Continue</button>
|
||||
<p class="auto-close">This window will close automatically in <span class="countdown" id="countdown">5</span> seconds...</p>
|
||||
|
||||
<script>
|
||||
// Signal OAuth success via localStorage
|
||||
try {
|
||||
localStorage.setItem('oauth_complete', Date.now().toString());
|
||||
console.log('OAuth complete flag set in localStorage');
|
||||
} catch (e) {
|
||||
console.error('Failed to set localStorage:', e);
|
||||
}
|
||||
|
||||
// Countdown timer
|
||||
let seconds = 5;
|
||||
const countdownEl = document.getElementById('countdown');
|
||||
const countdownTimer = setInterval(function() {
|
||||
seconds--;
|
||||
countdownEl.textContent = seconds;
|
||||
if (seconds <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
closeAndRefresh();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Close popup and trigger parent refresh
|
||||
function closeAndRefresh() {
|
||||
// Try to close the popup
|
||||
try {
|
||||
window.close();
|
||||
} catch (e) {
|
||||
console.log('Could not close window:', e);
|
||||
}
|
||||
|
||||
// If window didn't close, redirect to connection page
|
||||
setTimeout(function() {
|
||||
window.location.href = '/connection';
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="icon error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>We couldn't complete the authorization with QuickBooks Online.</p>
|
||||
{% if error %}
|
||||
<div class="error-msg">{{ error }}</div>
|
||||
{% endif %}
|
||||
<a href="/connection" class="btn">Try Again</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings - QBO Excel Sync{% endblock %}
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h2>Settings</h2>
|
||||
<p>Configure import behavior and application preferences.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<section class="card">
|
||||
<div class="card-header"><h3>Import Settings</h3></div>
|
||||
<div class="card-body">
|
||||
<form id="settings-form" class="form">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="skip_duplicates" {% if import_settings.skip_duplicates %}checked{% endif %}>
|
||||
<span>Skip duplicate records</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duplicate_fields">Duplicate check fields</label>
|
||||
<input type="text" id="duplicate_fields" value="{{ import_settings.duplicate_check_fields | join(', ') }}" placeholder="DocNumber, RefNumber">
|
||||
<small>Comma-separated list of fields to check for duplicates</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="batch_size">Batch size</label>
|
||||
<input type="number" id="batch_size" value="{{ import_settings.batch_size }}" min="1" max="200">
|
||||
<small>Number of records to process per batch (1-200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="validate_before" {% if import_settings.validate_before_import %}checked{% endif %}>
|
||||
<span>Validate data before import</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="create_missing" {% if import_settings.create_missing_references %}checked{% endif %}>
|
||||
<span>Auto-create missing customers/vendors</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h3>Format Settings</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="date_format">Date format</label>
|
||||
<select id="date_format">
|
||||
<option value="%Y-%m-%d" {% if import_settings.date_format == '%Y-%m-%d' %}selected{% endif %}>YYYY-MM-DD (2024-01-15)</option>
|
||||
<option value="%m/%d/%Y" {% if import_settings.date_format == '%m/%d/%Y' %}selected{% endif %}>MM/DD/YYYY (01/15/2024)</option>
|
||||
<option value="%d/%m/%Y" {% if import_settings.date_format == '%d/%m/%Y' %}selected{% endif %}>DD/MM/YYYY (15/01/2024)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="decimal_sep">Decimal separator</label>
|
||||
<select id="decimal_sep">
|
||||
<option value="." {% if import_settings.decimal_separator == '.' %}selected{% endif %}>Period (1234.56)</option>
|
||||
<option value="," {% if import_settings.decimal_separator == ',' %}selected{% endif %}>Comma (1234,56)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thousand_sep">Thousand separator</label>
|
||||
<select id="thousand_sep">
|
||||
<option value="," {% if import_settings.thousand_separator == ',' %}selected{% endif %}>Comma (1,234)</option>
|
||||
<option value="." {% if import_settings.thousand_separator == '.' %}selected{% endif %}>Period (1.234)</option>
|
||||
<option value=" " {% if import_settings.thousand_separator == ' ' %}selected{% endif %}>Space (1 234)</option>
|
||||
<option value="" {% if import_settings.thousand_separator == '' %}selected{% endif %}>None (1234)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h3>API Settings</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="environment">Environment</label>
|
||||
<select id="environment">
|
||||
<option value="sandbox" {% if environment == 'sandbox' %}selected{% endif %}>Sandbox (Testing)</option>
|
||||
<option value="production" {% if environment == 'production' %}selected{% endif %}>Production (Live)</option>
|
||||
</select>
|
||||
<small>Sandbox uses test data. Production uses real QuickBooks data.</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header"><h3>Data Management</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="button-group">
|
||||
<button class="btn btn-danger" id="clear-credentials">Clear Saved Credentials</button>
|
||||
<button class="btn btn-secondary" id="clear-templates">Reset All Templates</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button class="btn btn-primary" id="save-settings">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('save-settings').addEventListener('click', saveSettings);
|
||||
document.getElementById('clear-credentials').addEventListener('click', clearCredentials);
|
||||
document.getElementById('clear-templates').addEventListener('click', clearTemplates);
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
const fields = document.getElementById('duplicate_fields').value;
|
||||
const duplicateFields = fields.split(',').map(f => f.trim()).filter(f => f);
|
||||
|
||||
const settings = {
|
||||
skip_duplicates: document.getElementById('skip_duplicates').checked,
|
||||
duplicate_check_fields: duplicateFields,
|
||||
batch_size: parseInt(document.getElementById('batch_size').value) || 50,
|
||||
validate_before_import: document.getElementById('validate_before').checked,
|
||||
create_missing_references: document.getElementById('create_missing').checked,
|
||||
date_format: document.getElementById('date_format').value,
|
||||
decimal_separator: document.getElementById('decimal_sep').value,
|
||||
thousand_separator: document.getElementById('thousand_sep').value,
|
||||
environment: document.getElementById('environment').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) showToast('Settings saved successfully', 'success');
|
||||
else showToast(result.error || 'Failed to save settings', 'error');
|
||||
} catch (error) {
|
||||
showToast('Error saving settings: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCredentials() {
|
||||
if (!confirm('This will remove all saved API credentials. Continue?')) return;
|
||||
try {
|
||||
const response = await fetch('/api/settings/clear-credentials', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (result.success) { showToast('Credentials cleared', 'success'); location.reload(); }
|
||||
else showToast(result.error || 'Failed to clear', 'error');
|
||||
} catch (error) { showToast('Error: ' + error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function clearTemplates() {
|
||||
if (!confirm('This will delete ALL custom templates. Continue?')) return;
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
for (const t of result.templates) {
|
||||
await fetch('/api/templates/' + encodeURIComponent(t.name), { method: 'DELETE' });
|
||||
}
|
||||
showToast('Templates cleared', 'success');
|
||||
}
|
||||
} catch (error) { showToast('Error: ' + error.message, 'error'); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,597 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Templates - QBO Excel Sync{% endblock %}
|
||||
{% block content %}
|
||||
<div class="templates-page">
|
||||
<div class="page-header">
|
||||
<h2>Mapping Templates</h2>
|
||||
<p>Create and manage field mapping templates for your imports.</p>
|
||||
</div>
|
||||
|
||||
<div class="templates-layout">
|
||||
<aside class="templates-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>Templates</h3>
|
||||
<button class="btn btn-primary btn-sm" id="new-template-btn">+ New</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select id="filter-type">
|
||||
<option value="">All Types</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="account">Account</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul class="template-list" id="template-list">
|
||||
{% for template in templates %}
|
||||
<li class="template-item" data-name="{{ template.name }}" data-type="{{ template.data_type }}">
|
||||
<span class="template-name">{{ template.name }}</span>
|
||||
<span class="template-type">{{ template.data_type }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if not templates %}
|
||||
<li class="empty-state">No custom templates yet</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="template-detail" id="template-detail">
|
||||
<div class="empty-detail"><p>Select a template or create a new one</p></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Modal -->
|
||||
<div class="modal" id="template-modal" hidden style="display: none;">
|
||||
<div class="modal-backdrop" id="modal-backdrop"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">New Template</h3>
|
||||
<button type="button" class="btn btn-icon" id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Template Name</label>
|
||||
<input type="text" id="template-name" placeholder="e.g., My Payroll Import" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data Type</label>
|
||||
<select id="template-type">
|
||||
<option value="check">Check</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="customer">Customer</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="account">Account</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section">
|
||||
<div class="mapping-header">
|
||||
<label>Field Mappings</label>
|
||||
<div class="mapping-actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="load-defaults">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Load Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="excel-columns-note" id="excel-columns-note" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
<span>Excel columns available from your last uploaded file</span>
|
||||
</div>
|
||||
|
||||
<table class="data-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%;">Excel Column</th>
|
||||
<th style="width: 35%;">QBO Field</th>
|
||||
<th style="width: 15%;">Transform</th>
|
||||
<th style="width: 5%;">Req</th>
|
||||
<th style="width: 15%;">Default Value</th>
|
||||
<th style="width: 5%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modal-mappings"></tbody>
|
||||
</table>
|
||||
|
||||
<button type="button" class="btn btn-sm" id="add-mapping-row">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="modal-save">Save Template</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-lg {
|
||||
max-width: 900px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.mapping-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mapping-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-header label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.excel-columns-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-table {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mapping-table select,
|
||||
.mapping-table input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapping-table input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select option {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.mapping-table .qbo-field-select optgroup {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.field-required {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.remove-row-btn {
|
||||
padding: 4px 8px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remove-row-btn:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let currentTemplate = null;
|
||||
let modalMappings = [];
|
||||
let qboFields = [];
|
||||
let excelColumns = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Template list click handlers
|
||||
document.querySelectorAll('.template-item').forEach(item => {
|
||||
item.addEventListener('click', () => loadTemplate(item.dataset.name));
|
||||
});
|
||||
|
||||
// Filter handler
|
||||
document.getElementById('filter-type').addEventListener('change', filterTemplates);
|
||||
|
||||
// New template button
|
||||
document.getElementById('new-template-btn').addEventListener('click', openNewTemplate);
|
||||
|
||||
// Data type change - reload QBO fields
|
||||
document.getElementById('template-type').addEventListener('change', loadQBOFields);
|
||||
|
||||
// Modal close handlers
|
||||
document.getElementById('modal-close').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-cancel').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-backdrop').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Modal action handlers
|
||||
document.getElementById('modal-save').addEventListener('click', saveTemplate);
|
||||
document.getElementById('load-defaults').addEventListener('click', loadDefaultFields);
|
||||
document.getElementById('add-mapping-row').addEventListener('click', addMappingRow);
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function filterTemplates() {
|
||||
const type = document.getElementById('filter-type').value;
|
||||
document.querySelectorAll('.template-item').forEach(item => {
|
||||
item.style.display = (!type || item.dataset.type === type) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTemplate(name) {
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const template = result.templates.find(t => t.name === name);
|
||||
if (template) {
|
||||
currentTemplate = template;
|
||||
renderTemplateDetail(template);
|
||||
document.querySelectorAll('.template-item').forEach(i => i.classList.toggle('active', i.dataset.name === name));
|
||||
}
|
||||
}
|
||||
} catch (error) { showToast('Error loading template', 'error'); }
|
||||
}
|
||||
|
||||
function renderTemplateDetail(template) {
|
||||
document.getElementById('template-detail').innerHTML =
|
||||
'<div class="detail-header"><h3>' + escapeHtml(template.name) + '</h3><span class="badge">' + template.data_type + '</span></div>' +
|
||||
'<div class="detail-actions"><button class="btn btn-secondary" onclick="editTemplate()">Edit</button><button class="btn btn-danger" onclick="deleteTemplate()">Delete</button></div>' +
|
||||
'<table class="data-table"><thead><tr><th>Excel Column</th><th>QBO Field</th><th>Transform</th><th>Required</th></tr></thead><tbody>' +
|
||||
template.mappings.map(m => '<tr><td>' + escapeHtml(m.excel_column || '-') + '</td><td>' + escapeHtml(m.qbo_field) + '</td><td>' + (m.transform || '-') + '</td><td>' + (m.required ? 'Yes' : 'No') + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
async function loadQBOFields() {
|
||||
const type = document.getElementById('template-type').value;
|
||||
try {
|
||||
const response = await fetch('/api/qbo-fields/' + type);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
qboFields = result.qbo_fields || [];
|
||||
excelColumns = result.excel_columns || [];
|
||||
|
||||
// Show/hide Excel columns note
|
||||
const note = document.getElementById('excel-columns-note');
|
||||
if (excelColumns.length > 0) {
|
||||
note.style.display = 'flex';
|
||||
} else {
|
||||
note.style.display = 'none';
|
||||
}
|
||||
|
||||
// Re-render mappings with new fields
|
||||
renderModalMappings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading QBO fields:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openNewTemplate() {
|
||||
currentTemplate = null;
|
||||
document.getElementById('modal-title').textContent = 'New Template';
|
||||
document.getElementById('template-name').value = '';
|
||||
document.getElementById('template-type').value = 'check';
|
||||
modalMappings = [];
|
||||
|
||||
// Load QBO fields for default type
|
||||
await loadQBOFields();
|
||||
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.hidden = false;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function editTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
document.getElementById('modal-title').textContent = 'Edit Template';
|
||||
document.getElementById('template-name').value = currentTemplate.name;
|
||||
document.getElementById('template-type').value = currentTemplate.data_type;
|
||||
modalMappings = JSON.parse(JSON.stringify(currentTemplate.mappings));
|
||||
|
||||
// Load QBO fields for this type
|
||||
await loadQBOFields();
|
||||
|
||||
const modal = document.getElementById('template-modal');
|
||||
modal.hidden = false;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('template-modal');
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalMappings() {
|
||||
const tbody = document.getElementById('modal-mappings');
|
||||
|
||||
// Build Excel columns options
|
||||
let excelOptions = '<option value="">-- Select or type --</option>';
|
||||
excelColumns.forEach(col => {
|
||||
excelOptions += '<option value="' + escapeHtml(col) + '">' + escapeHtml(col) + '</option>';
|
||||
});
|
||||
|
||||
// Build QBO fields options grouped by required/optional
|
||||
let qboOptions = '<option value="">-- Select QBO Field --</option>';
|
||||
|
||||
const requiredFields = qboFields.filter(f => f.required);
|
||||
const optionalFields = qboFields.filter(f => !f.required);
|
||||
|
||||
if (requiredFields.length > 0) {
|
||||
qboOptions += '<optgroup label="Required Fields">';
|
||||
requiredFields.forEach(f => {
|
||||
qboOptions += '<option value="' + escapeHtml(f.field) + '" data-transform="' + (f.transform || '') + '">' +
|
||||
escapeHtml(f.label) + ' *</option>';
|
||||
});
|
||||
qboOptions += '</optgroup>';
|
||||
}
|
||||
|
||||
if (optionalFields.length > 0) {
|
||||
qboOptions += '<optgroup label="Optional Fields">';
|
||||
optionalFields.forEach(f => {
|
||||
qboOptions += '<option value="' + escapeHtml(f.field) + '" data-transform="' + (f.transform || '') + '">' +
|
||||
escapeHtml(f.label) + '</option>';
|
||||
});
|
||||
qboOptions += '</optgroup>';
|
||||
}
|
||||
|
||||
tbody.innerHTML = modalMappings.map((m, i) => {
|
||||
// Check if excel_column matches any in the list
|
||||
const excelSelected = excelColumns.includes(m.excel_column);
|
||||
|
||||
// Check if qbo_field matches any in the list
|
||||
const qboMatch = qboFields.find(f => f.field === m.qbo_field);
|
||||
|
||||
return '<tr>' +
|
||||
// Excel Column - combo of select and input
|
||||
'<td>' +
|
||||
'<input type="text" list="excel-list-' + i + '" value="' + escapeHtml(m.excel_column || '') + '" ' +
|
||||
'data-index="' + i + '" data-field="excel_column" placeholder="Type or select...">' +
|
||||
'<datalist id="excel-list-' + i + '">' +
|
||||
excelColumns.map(col => '<option value="' + escapeHtml(col) + '">').join('') +
|
||||
'</datalist>' +
|
||||
'</td>' +
|
||||
// QBO Field - dropdown
|
||||
'<td>' +
|
||||
'<select class="qbo-field-select" data-index="' + i + '" data-field="qbo_field">' +
|
||||
qboOptions.replace(
|
||||
'value="' + escapeHtml(m.qbo_field || '') + '"',
|
||||
'value="' + escapeHtml(m.qbo_field || '') + '" selected'
|
||||
) +
|
||||
'</select>' +
|
||||
'</td>' +
|
||||
// Transform
|
||||
'<td>' +
|
||||
'<select data-index="' + i + '" data-field="transform">' +
|
||||
'<option value="">None</option>' +
|
||||
'<option value="date"' + (m.transform === 'date' ? ' selected' : '') + '>Date</option>' +
|
||||
'<option value="currency"' + (m.transform === 'currency' ? ' selected' : '') + '>Currency</option>' +
|
||||
'<option value="number"' + (m.transform === 'number' ? ' selected' : '') + '>Number</option>' +
|
||||
'</select>' +
|
||||
'</td>' +
|
||||
// Required checkbox
|
||||
'<td style="text-align: center;">' +
|
||||
'<input type="checkbox" data-index="' + i + '" data-field="required"' + (m.required ? ' checked' : '') + '>' +
|
||||
'</td>' +
|
||||
// Default value
|
||||
'<td>' +
|
||||
'<input type="text" value="' + escapeHtml(m.default_value || '') + '" data-index="' + i + '" data-field="default_value" placeholder="Default">' +
|
||||
'</td>' +
|
||||
// Remove button
|
||||
'<td>' +
|
||||
'<button type="button" class="remove-row-btn" onclick="removeMappingRow(' + i + ')">×</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
// Add event listeners
|
||||
tbody.querySelectorAll('input, select').forEach(el => {
|
||||
el.addEventListener('change', updateModalMapping);
|
||||
});
|
||||
|
||||
// Auto-set transform when QBO field changes
|
||||
tbody.querySelectorAll('.qbo-field-select').forEach(select => {
|
||||
select.addEventListener('change', function(e) {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
const transform = selectedOption.dataset.transform;
|
||||
|
||||
// Auto-set transform if defined for this field
|
||||
if (transform) {
|
||||
const transformSelect = tbody.querySelector('select[data-index="' + idx + '"][data-field="transform"]');
|
||||
if (transformSelect) {
|
||||
transformSelect.value = transform;
|
||||
modalMappings[idx].transform = transform;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set required if it's a required field
|
||||
const qboField = qboFields.find(f => f.field === e.target.value);
|
||||
if (qboField && qboField.required) {
|
||||
const reqCheckbox = tbody.querySelector('input[data-index="' + idx + '"][data-field="required"]');
|
||||
if (reqCheckbox) {
|
||||
reqCheckbox.checked = true;
|
||||
modalMappings[idx].required = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateModalMapping(e) {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
const field = e.target.dataset.field;
|
||||
if (field === 'required') {
|
||||
modalMappings[idx][field] = e.target.checked;
|
||||
} else {
|
||||
modalMappings[idx][field] = e.target.value || null;
|
||||
}
|
||||
}
|
||||
|
||||
function addMappingRow() {
|
||||
modalMappings.push({
|
||||
excel_column: '',
|
||||
qbo_field: '',
|
||||
transform: null,
|
||||
required: false,
|
||||
default_value: null
|
||||
});
|
||||
renderModalMappings();
|
||||
|
||||
// Scroll to the new row
|
||||
const tbody = document.getElementById('modal-mappings');
|
||||
const lastRow = tbody.lastElementChild;
|
||||
if (lastRow) {
|
||||
lastRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function removeMappingRow(idx) {
|
||||
modalMappings.splice(idx, 1);
|
||||
renderModalMappings();
|
||||
}
|
||||
|
||||
async function loadDefaultFields() {
|
||||
const type = document.getElementById('template-type').value;
|
||||
try {
|
||||
const response = await fetch('/api/templates/default/' + type);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
modalMappings = result.template.mappings;
|
||||
renderModalMappings();
|
||||
showToast('Default fields loaded', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error loading defaults', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const name = document.getElementById('template-name').value.trim();
|
||||
const type = document.getElementById('template-type').value;
|
||||
|
||||
if (!name) {
|
||||
showToast('Please enter a template name', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out empty mappings
|
||||
const validMappings = modalMappings.filter(m => m.qbo_field);
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
showToast('Please add at least one field mapping', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
data_type: type,
|
||||
mappings: validMappings
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
showToast('Template saved successfully', 'success');
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
showToast(result.error || 'Failed to save template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error saving template: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate() {
|
||||
if (!currentTemplate) return;
|
||||
if (!confirm('Are you sure you want to delete template "' + currentTemplate.name + '"?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/templates/' + encodeURIComponent(currentTemplate.name), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast('Template deleted successfully', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.error || 'Failed to delete template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error deleting template: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(text);
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user