8fb6468369
- generate_receipt.py: Creates PDF donation receipts - update_ledger.py: Updates Excel ledger with donations - ledger_helper.py: Receipt number management - process_donation.py: Donation processing workflow
306 lines
10 KiB
Python
Executable File
306 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
FJCCV Ledger Auto-Update
|
|
Automatically add donation entries to the Excel ledger.
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import openpyxl
|
|
from openpyxl.styles import Font, Alignment
|
|
|
|
def get_ledger_path():
|
|
"""Get the path to the FJCCV ledger"""
|
|
# Primary location: workspace root
|
|
workspace = Path.home() / ".openclaw/agents/main/workspace"
|
|
primary_ledger = workspace / "FJCCV_Ledger.xlsx"
|
|
|
|
if primary_ledger.exists():
|
|
return primary_ledger
|
|
|
|
# Fallback: skill directory
|
|
script_dir = Path(__file__).parent
|
|
skill_dir = script_dir.parent
|
|
return skill_dir / "FJCCV_Ledger.xlsx"
|
|
|
|
def calculate_stripe_fee(gross_amount: float) -> float:
|
|
"""
|
|
Calculate Stripe fee for a donation.
|
|
Stripe charges 2.9% + $0.30 per transaction.
|
|
"""
|
|
return (gross_amount * 0.029) + 0.30
|
|
|
|
def add_donation(ledger_path: Path, date_str: str, donor: str, gross_amount: float,
|
|
payment_method: str, transaction_id: str = None,
|
|
stripe_fee: float = None) -> dict:
|
|
"""
|
|
Add a donation entry to the ledger.
|
|
|
|
Returns dict with:
|
|
- success: bool
|
|
- receipt_number: str (if successful)
|
|
- row_number: int (if successful)
|
|
- message: str
|
|
"""
|
|
|
|
# Parse date
|
|
try:
|
|
if '/' in date_str:
|
|
# Handle MM/DD/YY or MM/DD/YYYY
|
|
parts = date_str.split('/')
|
|
if len(parts[2]) == 2:
|
|
# Two-digit year - assume 20xx
|
|
year = 2000 + int(parts[2])
|
|
else:
|
|
year = int(parts[2])
|
|
date_obj = datetime(year, int(parts[0]), int(parts[1]))
|
|
else:
|
|
# ISO format YYYY-MM-DD
|
|
date_obj = datetime.fromisoformat(date_str)
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Invalid date format: {e}"
|
|
}
|
|
|
|
year = date_obj.year
|
|
|
|
# Load workbook
|
|
try:
|
|
wb = openpyxl.load_workbook(ledger_path)
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Could not load ledger: {e}"
|
|
}
|
|
|
|
# Find or create donations sheet for year
|
|
sheet_name = f"Donations {year}"
|
|
if sheet_name not in wb.sheetnames:
|
|
return {
|
|
'success': False,
|
|
'message': f"Sheet '{sheet_name}' not found. Create it manually first."
|
|
}
|
|
|
|
ws = wb[sheet_name]
|
|
|
|
# Calculate Stripe fee if using Stripe and not provided
|
|
if payment_method and 'stripe' in payment_method.lower():
|
|
if stripe_fee is None:
|
|
stripe_fee = calculate_stripe_fee(gross_amount)
|
|
net_amount = gross_amount - stripe_fee
|
|
else:
|
|
stripe_fee = 0
|
|
net_amount = gross_amount
|
|
|
|
# Determine receipt number
|
|
from ledger_helper import get_next_receipt_number
|
|
result = get_next_receipt_number(ledger_path, donor, year)
|
|
if not result:
|
|
return {
|
|
'success': False,
|
|
'message': "Could not calculate receipt number"
|
|
}
|
|
|
|
receipt_number, donor_id, donation_count, total_with_initial = result
|
|
|
|
# Find the last data row (before totals/summaries)
|
|
last_data_row = 1
|
|
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
|
date_val = row[0]
|
|
# Stop when we hit empty rows or formula rows (TOTAL, By Donor, etc.)
|
|
if date_val is None or (isinstance(date_val, str) and 'TOTAL' in date_val.upper()):
|
|
break
|
|
last_data_row = row_idx
|
|
|
|
# Insert new row after last data row
|
|
insert_row = last_data_row + 1
|
|
|
|
# Write data
|
|
ws.cell(row=insert_row, column=1).value = date_obj # Date
|
|
ws.cell(row=insert_row, column=2).value = donor # Donor
|
|
ws.cell(row=insert_row, column=3).value = gross_amount # Gross Amount
|
|
ws.cell(row=insert_row, column=4).value = stripe_fee # Stripe Fee
|
|
ws.cell(row=insert_row, column=5).value = net_amount # Net Amount
|
|
ws.cell(row=insert_row, column=6).value = receipt_number # Receipt #
|
|
ws.cell(row=insert_row, column=7).value = transaction_id if transaction_id else "" # Transaction ID
|
|
|
|
# Format date cell
|
|
ws.cell(row=insert_row, column=1).number_format = 'M/D/YYYY'
|
|
|
|
# Format currency cells
|
|
for col in [3, 4, 5]: # Gross, Fee, Net
|
|
ws.cell(row=insert_row, column=col).number_format = '#,##0.00'
|
|
|
|
# Save workbook
|
|
try:
|
|
wb.save(ledger_path)
|
|
return {
|
|
'success': True,
|
|
'receipt_number': receipt_number,
|
|
'row_number': insert_row,
|
|
'net_amount': net_amount,
|
|
'stripe_fee': stripe_fee,
|
|
'message': f"Added donation to row {insert_row} in '{sheet_name}'"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Failed to save ledger: {e}"
|
|
}
|
|
|
|
def add_bank_reconciliation(ledger_path: Path, date_str: str, donor: str,
|
|
net_amount: float, description: str = None) -> dict:
|
|
"""
|
|
Add a bank reconciliation entry for a donation.
|
|
Returns dict with success status.
|
|
"""
|
|
|
|
# Parse date
|
|
try:
|
|
if '/' in date_str:
|
|
parts = date_str.split('/')
|
|
if len(parts[2]) == 2:
|
|
year = 2000 + int(parts[2])
|
|
else:
|
|
year = int(parts[2])
|
|
date_obj = datetime(year, int(parts[0]), int(parts[1]))
|
|
else:
|
|
date_obj = datetime.fromisoformat(date_str)
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Invalid date format: {e}"
|
|
}
|
|
|
|
# Load workbook
|
|
try:
|
|
wb = openpyxl.load_workbook(ledger_path, data_only=False)
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Could not load ledger: {e}"
|
|
}
|
|
|
|
if "Bank Reconciliation" not in wb.sheetnames:
|
|
return {
|
|
'success': False,
|
|
'message': "Bank Reconciliation sheet not found"
|
|
}
|
|
|
|
ws = wb["Bank Reconciliation"]
|
|
|
|
# Find last row with data
|
|
last_row = 1
|
|
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
|
if row[0]: # Has a date
|
|
last_row = row_idx
|
|
|
|
insert_row = last_row + 1
|
|
|
|
# Get previous running balance
|
|
prev_balance = ws.cell(row=last_row, column=5).value or 0
|
|
if isinstance(prev_balance, str):
|
|
# It's a formula, try to get the value
|
|
prev_balance = 0
|
|
|
|
new_balance = prev_balance + net_amount
|
|
|
|
# Description
|
|
if not description:
|
|
description = f"FJCCV Donation ({donor} ${net_amount:,.2f})"
|
|
|
|
# Write data
|
|
ws.cell(row=insert_row, column=1).value = date_obj # Date
|
|
ws.cell(row=insert_row, column=2).value = description # Description
|
|
ws.cell(row=insert_row, column=3).value = net_amount # Amount
|
|
ws.cell(row=insert_row, column=4).value = "Donation" # Category
|
|
ws.cell(row=insert_row, column=5).value = new_balance # Running Balance
|
|
|
|
# Format cells
|
|
ws.cell(row=insert_row, column=1).number_format = 'M/D/YYYY'
|
|
ws.cell(row=insert_row, column=3).number_format = '#,##0.00'
|
|
ws.cell(row=insert_row, column=5).number_format = '#,##0.00'
|
|
|
|
# Save
|
|
try:
|
|
wb.save(ledger_path)
|
|
return {
|
|
'success': True,
|
|
'row_number': insert_row,
|
|
'new_balance': new_balance,
|
|
'message': f"Added bank reconciliation entry to row {insert_row}"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'message': f"Failed to save ledger: {e}"
|
|
}
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Add donation to FJCCV ledger')
|
|
parser.add_argument('--date', required=True, help='Date (MM/DD/YY or YYYY-MM-DD)')
|
|
parser.add_argument('--donor', required=True, help='Donor name')
|
|
parser.add_argument('--amount', required=True, type=float, help='Gross donation amount')
|
|
parser.add_argument('--payment-method', required=True, help='Payment method (Stripe, PayPal, Zelle, etc.)')
|
|
parser.add_argument('--transaction', help='Transaction ID (optional)')
|
|
parser.add_argument('--stripe-fee', type=float, help='Custom Stripe fee (auto-calculated if using Stripe)')
|
|
parser.add_argument('--bank-reconciliation', action='store_true',
|
|
help='Also add entry to Bank Reconciliation sheet')
|
|
parser.add_argument('--dry-run', action='store_true', help='Show what would be added without saving')
|
|
|
|
args = parser.parse_args()
|
|
|
|
ledger_path = get_ledger_path()
|
|
if not ledger_path.exists():
|
|
print(f"❌ Error: Ledger not found at {ledger_path}")
|
|
return 1
|
|
|
|
if args.dry_run:
|
|
print("🔍 DRY RUN - No changes will be saved\n")
|
|
|
|
# Add donation
|
|
result = add_donation(
|
|
ledger_path=ledger_path,
|
|
date_str=args.date,
|
|
donor=args.donor,
|
|
gross_amount=args.amount,
|
|
payment_method=args.payment_method,
|
|
transaction_id=args.transaction,
|
|
stripe_fee=args.stripe_fee
|
|
)
|
|
|
|
if not result['success']:
|
|
print(f"❌ Error: {result['message']}")
|
|
return 1
|
|
|
|
print(f"✅ Donation added successfully")
|
|
print(f" Receipt #: {result['receipt_number']}")
|
|
print(f" Gross: ${args.amount:,.2f}")
|
|
print(f" Fee: ${result['stripe_fee']:,.2f}")
|
|
print(f" Net: ${result['net_amount']:,.2f}")
|
|
print(f" Row: {result['row_number']}")
|
|
|
|
# Add bank reconciliation if requested
|
|
if args.bank_reconciliation:
|
|
bank_result = add_bank_reconciliation(
|
|
ledger_path=ledger_path,
|
|
date_str=args.date,
|
|
donor=args.donor,
|
|
net_amount=result['net_amount']
|
|
)
|
|
|
|
if bank_result['success']:
|
|
print(f"\n✅ Bank reconciliation added")
|
|
print(f" Row: {bank_result['row_number']}")
|
|
print(f" New Balance: ${bank_result['new_balance']:,.2f}")
|
|
else:
|
|
print(f"\n⚠️ Warning: Could not add bank reconciliation: {bank_result['message']}")
|
|
|
|
return 0
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|