Initial Codes

This commit is contained in:
2026-02-13 10:38:45 -05:00
parent 0dd81218e0
commit 40a151787f
24 changed files with 7627 additions and 1 deletions
+4
View File
@@ -1,3 +1,7 @@
#My folders
upload/
.flask_session/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]
+244 -1
View File
@@ -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
+1318
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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"}
+17
View File
@@ -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"
}
+64
View File
@@ -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"
}
+280
View File
@@ -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
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
Flask>=2.3.0
Flask-Session>=0.5.0
openpyxl>=3.1.0
requests>=2.31.0
+416
View File
@@ -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
+351
View File
@@ -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"),
]
),
}
+680
View File
@@ -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
+719
View File
@@ -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)
+234
View File
@@ -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; }
}
+198
View File
@@ -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()">&times;</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
};
+28
View File
@@ -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 %}
+30
View File
@@ -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 %}
+108
View File
@@ -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>
+508
View File
@@ -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
+143
View File
@@ -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 %}
+127
View File
@@ -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>
+176
View File
@@ -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 %}
+597
View File
@@ -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">&times;</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 + ')">&times;</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 %}