I wrote a beautiful Python script that automated three hours of daily work for our accounting team. Elegant code. Comprehensive error handling. Well-tested.
Nobody used it.
The problem wasn't the code. It was the interface. Running it required:
1. Opening Terminal (terrifying for Excel users)
2. Activating a virtual environment (what's a virtual environment?)
3. Running python reconcile_payments.py --start-date 2024-01-01 --end-date 2024-01-31 --output ./reports/ (what's a flag?)
4. Checking logs to confirm it worked (where are logs?)
I rewrote the interface. Same logic, different wrapper. Now they double-click an icon, pick dates from dropdowns, and click "Run." They use it every day.
The automation didn't change. The user experience did.
Principle 1: Configuration Over Code¶
Finance users should never edit code to change behavior. Never.
Bad:
# reconcile_payments.py
# Configuration
START_DATE = "2024-01-01" # Change this!
END_DATE = "2024-01-31" # Change this!
OUTPUT_DIR = "./reports" # Change this!
Good:
# reconcile_payments.py
import json
from pathlib import Path
def load_config():
"""Load configuration from JSON file."""
config_path = Path.home() / ".payment_reconciliation" / "config.json"
if not config_path.exists():
# Create default config on first run
config_path.parent.mkdir(parents=True, exist_ok=True)
default_config = {
"output_directory": str(Path.home() / "Documents" / "Payment Reports"),
"netsuite_account_id": "",
"email_recipients": ["accounting@company.com"],
"auto_send_email": false
}
with open(config_path, 'w') as f:
json.dump(default_config, f, indent=2)
print(f"Created default config at {config_path}")
print("Please edit this file with your settings.")
return None
with open(config_path) as f:
return json.load(f)
config = load_config()
if config is None:
exit(1)
Users edit a simple JSON file, not Python code. They can't break the script with a syntax error. You can add helpful comments in the JSON:
{
"_comment": "Payment Reconciliation Configuration",
"_comment_output": "Where to save reports (must be an existing folder)",
"output_directory": "C:/Users/jsmith/Documents/Payment Reports",
"_comment_netsuite": "Your NetSuite account ID (ask IT if you don't know)",
"netsuite_account_id": "1234567",
"_comment_email": "Who should receive the report when it completes",
"email_recipients": [
"accounting@company.com",
"controller@company.com"
],
"auto_send_email": true
}
JSON doesn't support comments officially, but keys starting with _comment work fine and make configs self-documenting.
Principle 2: CLI Design for Humans¶
If your script has a command-line interface, make it intuitive.
Bad:
import sys
start = sys.argv[1]
end = sys.argv[2]
output = sys.argv[3]
Good:
import argparse
from datetime import datetime, timedelta
def parse_args():
parser = argparse.ArgumentParser(
description="Reconcile payments between Stripe and NetSuite",
epilog="Example: python reconcile_payments.py --month 2024-01"
)
parser.add_argument(
'--month',
type=str,
help='Month to reconcile (YYYY-MM format). Defaults to last month.',
default=(datetime.now().replace(day=1) - timedelta(days=1)).strftime('%Y-%m')
)
parser.add_argument(
'--output',
type=str,
help='Output directory. Defaults to config file setting.',
default=None
)
parser.add_argument(
'--send-email',
action='store_true',
help='Send email report when complete.'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Run without making any changes (test mode).'
)
return parser.parse_args()
args = parse_args()
Key improvements:
- Help text for every argument (
--helpshows usage) - Sensible defaults (last month if not specified)
- Clear format expectations (YYYY-MM)
- Flags don't require values (
--send-email, not--send-email true) - Dry run mode for testing
Running it:
# Use defaults (last month)
python reconcile_payments.py
# Specific month
python reconcile_payments.py --month 2024-01
# Test without making changes
python reconcile_payments.py --month 2024-01 --dry-run
# See all options
python reconcile_payments.py --help
Much better than memorizing positional arguments.
Principle 3: Error Messages a Human Can Understand¶
Bad error message:
Traceback (most recent call last):
File "reconcile.py", line 47, in <module>
ns_data = ns.get_invoices(start_date, end_date)
File "netsuite_client.py", line 123, in get_invoices
response = requests.post(url, headers=headers)
File "requests/models.py", line 234, in post
raise ConnectionError("Max retries exceeded")
ConnectionError: Max retries exceeded with url: https://...
That's what the user sees. They don't know what to do with it.
Good error message:
❌ ERROR: Cannot connect to NetSuite
The script couldn't reach NetSuite's servers. This usually means:
1. Your internet connection is down
2. NetSuite is having issues (check status.netsuite.com)
3. Your NetSuite credentials expired
What to do:
- Check your internet connection
- Try again in a few minutes
- If it keeps failing, contact IT with this error code: NS_CONNECTION_001
Technical details (for IT):
ConnectionError: Max retries exceeded with url: https://1234567.suitetalk.api.netsuite.com/...
Implementation:
import logging
from typing import Optional
class UserFriendlyException(Exception):
"""Exception with user-friendly message and technical details."""
def __init__(self, user_message: str, technical_details: str, error_code: str):
self.user_message = user_message
self.technical_details = technical_details
self.error_code = error_code
super().__init__(user_message)
def display(self):
"""Display formatted error message."""
print("\n" + "="*60)
print("❌ ERROR: " + self.user_message)
print("="*60)
print(f"\nError code: {self.error_code}")
print(f"\nTechnical details (for IT):")
print(f"{self.technical_details}")
print("="*60 + "\n")
def get_netsuite_data(start_date, end_date):
"""Get data from NetSuite with friendly error handling."""
try:
ns = NetSuiteClient.from_config()
return ns.get_invoices(start_date, end_date)
except ConnectionError as e:
raise UserFriendlyException(
user_message=(
"Cannot connect to NetSuite\n\n"
"The script couldn't reach NetSuite's servers. This usually means:\n"
"1. Your internet connection is down\n"
"2. NetSuite is having issues (check status.netsuite.com)\n"
"3. Your VPN isn't connected\n\n"
"What to do:\n"
"- Check your internet connection\n"
"- Connect to VPN if required\n"
"- Try again in a few minutes\n"
"- If it keeps failing, contact IT"
),
technical_details=str(e),
error_code="NS_CONNECTION_001"
)
except AuthenticationError as e:
raise UserFriendlyException(
user_message=(
"NetSuite login failed\n\n"
"Your NetSuite credentials aren't working. This usually means:\n"
"1. Your password changed recently\n"
"2. Your API token expired\n"
"3. Your account was locked\n\n"
"What to do:\n"
"- Contact IT to reset your NetSuite API credentials\n"
"- Provide them with this error code"
),
technical_details=str(e),
error_code="NS_AUTH_002"
)
except Exception as e:
# Unexpected errors still show details
raise UserFriendlyException(
user_message="An unexpected error occurred",
technical_details=f"{type(e).__name__}: {str(e)}",
error_code="UNKNOWN_999"
)
# In main script
if __name__ == "__main__":
try:
data = get_netsuite_data(start_date, end_date)
# ... process data ...
print("✅ Reconciliation complete!")
except UserFriendlyException as e:
e.display()
exit(1)
Users get actionable guidance. IT gets technical details when they forward the error.
Principle 4: Progress Indication and Feedback¶
Finance scripts often process thousands of records. Give feedback.
Bad:
# Script runs silently for 5 minutes
# User assumes it's broken and kills it
for customer in customers:
process_customer(customer)
Good:
from tqdm import tqdm
import time
print("Processing 1,247 customers...")
print("This usually takes 3-5 minutes.\n")
for customer in tqdm(customers, desc="Reconciling payments", unit="customer"):
process_customer(customer)
time.sleep(0.1) # Rate limiting
print("\n✅ Processing complete!")
Users see:
Processing 1,247 customers...
This usually takes 3-5 minutes.
Reconciling payments: 42%|████████▌ | 523/1247 [01:34<02:09, 5.58customer/s]
They know it's working. They know how long it will take. They don't panic.
For multi-step processes:
def run_reconciliation():
"""Run full reconciliation with step-by-step feedback."""
steps = [
("Connecting to NetSuite", connect_netsuite),
("Connecting to Stripe", connect_stripe),
("Downloading NetSuite invoices", download_invoices),
("Downloading Stripe charges", download_charges),
("Matching payments", match_payments),
("Generating report", generate_report),
("Sending email", send_email)
]
total_steps = len(steps)
for i, (step_name, step_func) in enumerate(steps, 1):
print(f"\n[{i}/{total_steps}] {step_name}...", end=" ", flush=True)
try:
result = step_func()
print("✅")
except Exception as e:
print("❌")
raise UserFriendlyException(
user_message=f"Failed at step: {step_name}",
technical_details=str(e),
error_code=f"STEP_{i:02d}"
)
print("\n🎉 All steps completed successfully!")
Output:
[1/7] Connecting to NetSuite... ✅
[2/7] Connecting to Stripe... ✅
[3/7] Downloading NetSuite invoices... ✅
[4/7] Downloading Stripe charges... ✅
[5/7] Matching payments... ✅
[6/7] Generating report... ✅
[7/7] Sending email... ✅
🎉 All steps completed successfully!
Clear. Informative. Reassuring.
Principle 5: Logging for Debugging (Not for Users)¶
Users shouldn't read logs. But you need logs for debugging.
Separate user output from debug logs:
import logging
from pathlib import Path
from datetime import datetime
def setup_logging(verbose=False):
"""
Set up dual logging:
- File: Detailed debug logs
- Console: Only important messages (unless verbose=True)
"""
log_dir = Path.home() / ".payment_reconciliation" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"reconciliation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
# Root logger - captures everything
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# File handler - detailed logs
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(
'%(asctime)s | %(levelname)s | %(name)s | %(message)s'
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# Console handler - only important stuff (unless verbose)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG if verbose else logging.WARNING)
console_formatter = logging.Formatter('%(levelname)s: %(message)s')
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
return log_file
# In script
log_file = setup_logging(verbose=args.verbose)
logger = logging.getLogger(__name__)
logger.debug("Starting reconciliation with parameters: %s", args) # File only
logger.info("Processing customer %s", customer_id) # File only
logger.warning("Customer %s has no payment method", customer_id) # Console + file
logger.error("Failed to process customer %s: %s", customer_id, error) # Console + file
print(f"\n📋 Detailed logs saved to: {log_file}")
print("Share this file with IT if you need help debugging.\n")
Users see clean output. Debug logs go to a file. When something breaks, they can share the log file with IT.
Principle 6: Graceful Degradation¶
Scripts should handle partial failures gracefully.
Bad:
# Script crashes on first error
for customer in customers:
process_customer(customer) # If one fails, all fails
Good:
results = {
'success': [],
'failed': [],
'skipped': []
}
for customer in tqdm(customers, desc="Processing"):
try:
process_customer(customer)
results['success'].append(customer.id)
except PaymentMethodMissing:
results['skipped'].append({
'id': customer.id,
'reason': 'No payment method on file'
})
logger.warning(f"Skipped {customer.id}: no payment method")
except Exception as e:
results['failed'].append({
'id': customer.id,
'reason': str(e)
})
logger.error(f"Failed to process {customer.id}: {e}")
# Summary report
print("\n" + "="*60)
print("RECONCILIATION SUMMARY")
print("="*60)
print(f"✅ Successful: {len(results['success'])} customers")
print(f"⚠️ Skipped: {len(results['skipped'])} customers (no payment method)")
print(f"❌ Failed: {len(results['failed'])} customers")
if results['failed']:
print("\nFailed customers:")
for failure in results['failed'][:10]: # Show first 10
print(f" - {failure['id']}: {failure['reason']}")
if len(results['failed']) > 10:
print(f" ... and {len(results['failed']) - 10} more (see log file)")
print("="*60 + "\n")
# Export failures to CSV for review
if results['failed']:
failures_file = Path(config['output_directory']) / f"failures_{datetime.now().strftime('%Y%m%d')}.csv"
pd.DataFrame(results['failed']).to_csv(failures_file, index=False)
print(f"Failed customer details saved to: {failures_file}\n")
Users get a complete picture. Partial success is still useful. Failed items are logged for follow-up.
Principle 7: Packaging for Distribution¶
Making your script easy to run:
Option 1: Virtual environment + batch file (Windows)
REM run_reconciliation.bat
@echo off
echo Starting Payment Reconciliation...
echo.
REM Activate virtual environment
call "%~dp0venv\Scripts\activate.bat"
REM Run script
python "%~dp0reconcile_payments.py" %*
REM Keep window open if there's an error
if errorlevel 1 (
echo.
echo Script failed. Press any key to close...
pause > nul
)
Users double-click run_reconciliation.bat. It handles the virtual environment and runs the script.
Option 2: Standalone executable (PyInstaller)
# Build standalone executable
pip install pyinstaller
pyinstaller --onefile \
--name "Payment Reconciliation" \
--add-data "config.json:." \
--icon=icon.ico \
reconcile_payments.py
Produces a single .exe file. No Python installation required. Users double-click it.
Option 3: Simple GUI wrapper (tkinter)
# gui_wrapper.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import datetime, timedelta
import threading
from reconcile_payments import run_reconciliation
class ReconciliationGUI:
def __init__(self, root):
self.root = root
root.title("Payment Reconciliation")
root.geometry("500x400")
# Month selection
ttk.Label(root, text="Select Month:").grid(row=0, column=0, padx=10, pady=10, sticky="w")
self.month_var = tk.StringVar(value=self.get_last_month())
month_entry = ttk.Entry(root, textvariable=self.month_var, width=20)
month_entry.grid(row=0, column=1, padx=10, pady=10, sticky="w")
ttk.Label(root, text="(YYYY-MM format)").grid(row=0, column=2, padx=10, pady=10, sticky="w")
# Output directory
ttk.Label(root, text="Save Report To:").grid(row=1, column=0, padx=10, pady=10, sticky="w")
self.output_var = tk.StringVar(value=str(Path.home() / "Documents"))
output_entry = ttk.Entry(root, textvariable=self.output_var, width=40)
output_entry.grid(row=1, column=1, padx=10, pady=10, sticky="w")
browse_btn = ttk.Button(root, text="Browse...", command=self.browse_output)
browse_btn.grid(row=1, column=2, padx=10, pady=10)
# Options
self.email_var = tk.BooleanVar(value=True)
ttk.Checkbutton(root, text="Send email when complete", variable=self.email_var).grid(
row=2, column=1, padx=10, pady=5, sticky="w"
)
self.dry_run_var = tk.BooleanVar(value=False)
ttk.Checkbutton(root, text="Test mode (don't make changes)", variable=self.dry_run_var).grid(
row=3, column=1, padx=10, pady=5, sticky="w"
)
# Run button
self.run_btn = ttk.Button(root, text="Run Reconciliation", command=self.run_script)
self.run_btn.grid(row=4, column=1, padx=10, pady=20)
# Progress display
self.progress_text = tk.Text(root, height=10, width=60, state='disabled')
self.progress_text.grid(row=5, column=0, columnspan=3, padx=10, pady=10)
# Status bar
self.status_var = tk.StringVar(value="Ready")
status_bar = ttk.Label(root, textvariable=self.status_var, relief=tk.SUNKEN)
status_bar.grid(row=6, column=0, columnspan=3, sticky="ew")
def get_last_month(self):
last_month = datetime.now().replace(day=1) - timedelta(days=1)
return last_month.strftime("%Y-%m")
def browse_output(self):
directory = filedialog.askdirectory()
if directory:
self.output_var.set(directory)
def log_message(self, message):
"""Add message to progress text widget."""
self.progress_text.config(state='normal')
self.progress_text.insert(tk.END, message + "\n")
self.progress_text.see(tk.END)
self.progress_text.config(state='disabled')
def run_script(self):
"""Run reconciliation in background thread."""
self.run_btn.config(state='disabled')
self.status_var.set("Running...")
self.progress_text.config(state='normal')
self.progress_text.delete(1.0, tk.END)
self.progress_text.config(state='disabled')
# Run in background thread so GUI doesn't freeze
thread = threading.Thread(target=self._run_reconciliation_thread)
thread.daemon = True
thread.start()
def _run_reconciliation_thread(self):
"""Background thread for running reconciliation."""
try:
self.log_message(f"Starting reconciliation for {self.month_var.get()}...")
# Call actual reconciliation function
result = run_reconciliation(
month=self.month_var.get(),
output_dir=self.output_var.get(),
send_email=self.email_var.get(),
dry_run=self.dry_run_var.get(),
log_callback=self.log_message # Pass callback for progress updates
)
self.log_message("\n✅ Reconciliation complete!")
self.log_message(f"Report saved to: {result['report_path']}")
self.status_var.set("Complete")
messagebox.showinfo("Success", "Reconciliation completed successfully!")
except Exception as e:
self.log_message(f"\n❌ Error: {str(e)}")
self.status_var.set("Failed")
messagebox.showerror("Error", f"Reconciliation failed:\n\n{str(e)}")
finally:
self.run_btn.config(state='normal')
if __name__ == "__main__":
root = tk.Tk()
app = ReconciliationGUI(root)
root.mainloop()
Users get a simple GUI. No command line. No configuration files to edit. Just click and run.
Principle 8: Documentation That Doesn't Require a Degree¶
Bad README:
# Payment Reconciliation
## Installation
```bash
git clone https://github.com/company/reconciliation
cd reconciliation
pip install -r requirements.txt
Usage¶
python reconcile_payments.py --month YYYY-MM --output DIR
**Good README:**
```markdown
# Payment Reconciliation Tool
Automatically matches Stripe payments to NetSuite invoices and generates a reconciliation report.
## For Accounting Team (Non-Technical Users)
### How to Run the Tool
1. **Double-click** `Run Reconciliation.bat` on your desktop
2. **Select the month** you want to reconcile (e.g., 2024-01)
3. **Choose where to save** the report
4. **Click "Run Reconciliation"**
5. Wait 3-5 minutes for it to complete
6. Find your report in the folder you selected
### Troubleshooting
**"Cannot connect to NetSuite"**
- Check your internet connection
- Make sure you're connected to VPN
- Contact IT if it still doesn't work
**"NetSuite login failed"**
- Your API credentials may have expired
- Contact IT to renew your access
**"No data found for this month"**
- Make sure the month format is YYYY-MM (e.g., 2024-01)
- Check that you have data in NetSuite for that month
### Getting Help
- For technical issues: Email it@company.com
- For report questions: Email accounting-manager@company.com
- Include the log file from `C:\Users\[YourName]\.payment_reconciliation\logs\`
---
## For IT / Developers
### Installation
[Technical setup instructions here]
### Configuration
[Technical configuration details here]
### Architecture
[Technical architecture documentation here]
Separate user docs from technical docs. Most users never need the technical section.
The Real Test¶
A script is usable when a non-technical person can:
- Run it successfully without asking for help
- Understand what it's doing from the progress messages
- Recover from common errors using the error messages
- Get the output they need in a format they can use
If they're calling you every time they run it, the script isn't done yet.
Results¶
After applying these principles to our payment reconciliation tool:
- Adoption rate: 0% → 100% of accounting team
- Support requests: 3-4 per week → 1 per month
- Time saved: 3 hours per day across the team
- Errors: Frequent manual mistakes → zero in 12 months
The accounting team now asks me to automate other processes. They trust that the tools will work and won't require a CS degree to operate.
Write Python for humans, not just for Python.