Initial commit: FJCCV receipt generator and ledger tools
- 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
This commit is contained in:
Executable
+305
@@ -0,0 +1,305 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user