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:
Krystie
2026-05-25 02:32:57 -07:00
commit 8fb6468369
5 changed files with 992 additions and 0 deletions
+305
View File
@@ -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())