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:
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.log
|
||||||
|
*.xlsx~
|
||||||
Executable
+264
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FJCCV Donation Receipt Generator
|
||||||
|
Generates professional PDF receipts for church donations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.colors import HexColor
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: reportlab not installed. Run: pip3 install reportlab --break-system-packages")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# FJCCV brand colors
|
||||||
|
FJCCV_PURPLE = HexColor('#6A0DAD') # Deep purple
|
||||||
|
|
||||||
|
# Church info
|
||||||
|
CHURCH_NAME = "Fellowship of Jesus Christ in California Valley"
|
||||||
|
CHURCH_EIN = "33-4360898"
|
||||||
|
BIBLE_VERSE = '"Each one must do just as he has purposed in his heart, not grudgingly or under compulsion, for God loves a cheerful giver."'
|
||||||
|
BIBLE_REF = "2 Corinthians 9:7 (NASB 1995)"
|
||||||
|
|
||||||
|
def register_fonts(base_dir: Path) -> bool:
|
||||||
|
"""Register FJCCV Serif font family if available"""
|
||||||
|
fonts_dir = base_dir / "assets"
|
||||||
|
fonts = {
|
||||||
|
'FJCCVSerif': 'FJCCVSerif-Regular.ttf',
|
||||||
|
'FJCCVSerif-Bold': 'FJCCVSerif-Bold.ttf',
|
||||||
|
'FJCCVSerif-Italic': 'FJCCVSerif-Italic.ttf',
|
||||||
|
'FJCCVSerif-BoldItalic': 'FJCCVSerif-BoldItalic.ttf',
|
||||||
|
}
|
||||||
|
|
||||||
|
all_found = True
|
||||||
|
for font_name, font_file in fonts.items():
|
||||||
|
font_path = fonts_dir / font_file
|
||||||
|
if font_path.exists():
|
||||||
|
try:
|
||||||
|
pdfmetrics.registerFont(TTFont(font_name, str(font_path)))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not register {font_name}: {e}")
|
||||||
|
all_found = False
|
||||||
|
else:
|
||||||
|
print(f"Warning: Font file not found: {font_file}")
|
||||||
|
all_found = False
|
||||||
|
|
||||||
|
return all_found
|
||||||
|
|
||||||
|
def generate_receipt(donor: str, amount: float, date_str: str, donation_number: str,
|
||||||
|
output_path: Path, transaction: str = None, payment_method: str = None,
|
||||||
|
base_dir: Path = None) -> bool:
|
||||||
|
"""Generate a donation receipt PDF"""
|
||||||
|
|
||||||
|
# Setup fonts
|
||||||
|
fonts_available = False
|
||||||
|
if base_dir:
|
||||||
|
fonts_available = register_fonts(base_dir)
|
||||||
|
|
||||||
|
if fonts_available:
|
||||||
|
font_regular = 'FJCCVSerif'
|
||||||
|
font_bold = 'FJCCVSerif-Bold'
|
||||||
|
font_italic = 'FJCCVSerif-Italic'
|
||||||
|
font_bold_italic = 'FJCCVSerif-BoldItalic'
|
||||||
|
else:
|
||||||
|
print("Using fallback fonts (Times-Roman)")
|
||||||
|
font_regular = 'Times-Roman'
|
||||||
|
font_bold = 'Times-Bold'
|
||||||
|
font_italic = 'Times-Italic'
|
||||||
|
font_bold_italic = 'Times-BoldItalic'
|
||||||
|
|
||||||
|
# Create PDF
|
||||||
|
c = canvas.Canvas(str(output_path), pagesize=letter)
|
||||||
|
width, height = letter
|
||||||
|
|
||||||
|
# Add letterhead if available
|
||||||
|
if base_dir:
|
||||||
|
letterhead_path = base_dir / "assets" / "fjccv_letterhead.png"
|
||||||
|
if letterhead_path.exists():
|
||||||
|
# Full width letterhead at top
|
||||||
|
c.drawImage(str(letterhead_path), 0, height - 1.5*inch,
|
||||||
|
width=width, height=1.5*inch, preserveAspectRatio=True)
|
||||||
|
y_position = height - 2.5*inch
|
||||||
|
else:
|
||||||
|
print("Warning: Letterhead image not found, skipping")
|
||||||
|
y_position = height - 1.5*inch
|
||||||
|
else:
|
||||||
|
y_position = height - 1.5*inch
|
||||||
|
|
||||||
|
# Title
|
||||||
|
c.setFont(font_bold, 24)
|
||||||
|
c.setFillColor(FJCCV_PURPLE)
|
||||||
|
c.drawCentredString(width/2, y_position, "Donation Receipt")
|
||||||
|
y_position -= 0.7*inch
|
||||||
|
|
||||||
|
# Reset to black for body text
|
||||||
|
c.setFillColor('black')
|
||||||
|
|
||||||
|
# Receipt details
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
left_margin = 1.25*inch
|
||||||
|
line_height = 0.3*inch
|
||||||
|
|
||||||
|
# Date
|
||||||
|
c.drawString(left_margin, y_position, "Date:")
|
||||||
|
c.setFont(font_bold, 12)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, date_str)
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
# Donation number
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
c.drawString(left_margin, y_position, "Donation Number:")
|
||||||
|
c.setFont(font_bold, 12)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, donation_number)
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
# Donor name
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
c.drawString(left_margin, y_position, "Received from:")
|
||||||
|
c.setFont(font_bold, 12)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, donor)
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
# Amount
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
c.drawString(left_margin, y_position, "Amount:")
|
||||||
|
c.setFont(font_bold, 14)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, f"${amount:,.2f}")
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
# Payment method (if provided)
|
||||||
|
if payment_method:
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
c.drawString(left_margin, y_position, "Payment Method:")
|
||||||
|
c.setFont(font_bold, 12)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, payment_method)
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
# Transaction ID (if provided)
|
||||||
|
if transaction:
|
||||||
|
c.setFont(font_regular, 12)
|
||||||
|
c.drawString(left_margin, y_position, "Transaction ID:")
|
||||||
|
c.setFont(font_regular, 10)
|
||||||
|
c.drawString(left_margin + 2*inch, y_position, transaction)
|
||||||
|
y_position -= line_height
|
||||||
|
|
||||||
|
y_position -= 0.3*inch
|
||||||
|
|
||||||
|
# Bible verse (centered, italic, wrapped, purple)
|
||||||
|
c.setFont(font_bold_italic, 11)
|
||||||
|
c.setFillColor(FJCCV_PURPLE)
|
||||||
|
# Wrap the verse manually for better readability
|
||||||
|
verse_lines = [
|
||||||
|
'"Each one must do just as he has purposed in his heart,',
|
||||||
|
'not grudgingly or under compulsion,',
|
||||||
|
'for God loves a cheerful giver."',
|
||||||
|
f"— {BIBLE_REF}"
|
||||||
|
]
|
||||||
|
for line in verse_lines:
|
||||||
|
c.drawCentredString(width/2, y_position, line)
|
||||||
|
y_position -= 0.25*inch
|
||||||
|
|
||||||
|
# Reset to black for remaining text
|
||||||
|
c.setFillColor('black')
|
||||||
|
|
||||||
|
y_position -= 0.3*inch
|
||||||
|
|
||||||
|
# Tax statement
|
||||||
|
c.setFont(font_regular, 10)
|
||||||
|
tax_lines = [
|
||||||
|
f"{CHURCH_NAME} is a 508(c)(1)(a) church organization.",
|
||||||
|
"No goods or services were provided in exchange for this donation.",
|
||||||
|
"This receipt may be used for tax deduction purposes as allowed by law."
|
||||||
|
]
|
||||||
|
for line in tax_lines:
|
||||||
|
c.drawCentredString(width/2, y_position, line)
|
||||||
|
y_position -= 0.22*inch
|
||||||
|
|
||||||
|
y_position -= 0.5*inch
|
||||||
|
|
||||||
|
# Closing
|
||||||
|
c.setFont(font_regular, 11)
|
||||||
|
c.drawString(left_margin, y_position, "Sincerely,")
|
||||||
|
y_position -= 0.25*inch
|
||||||
|
|
||||||
|
c.setFont(font_bold, 11)
|
||||||
|
c.drawString(left_margin, y_position, CHURCH_NAME)
|
||||||
|
y_position -= 0.2*inch
|
||||||
|
|
||||||
|
c.setFont(font_regular, 10)
|
||||||
|
c.drawString(left_margin, y_position, f"EIN: {CHURCH_EIN}")
|
||||||
|
|
||||||
|
# Footer with generation timestamp
|
||||||
|
c.setFont(font_regular, 8)
|
||||||
|
c.setFillColor('gray')
|
||||||
|
footer_text = f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
c.drawCentredString(width/2, 0.5*inch, footer_text)
|
||||||
|
|
||||||
|
# Save PDF
|
||||||
|
c.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Generate FJCCV donation receipt PDF')
|
||||||
|
parser.add_argument('--donor', required=True, help='Full name of donor')
|
||||||
|
parser.add_argument('--amount', required=True, type=float, help='Donation amount (numeric)')
|
||||||
|
parser.add_argument('--date', required=True, help='Date of donation (MM/DD/YY)')
|
||||||
|
parser.add_argument('--donation-number', required=True, help='Donation number (LDDDDDD-YYYY)')
|
||||||
|
parser.add_argument('--transaction', help='Transaction ID (optional)')
|
||||||
|
parser.add_argument('--payment-method', help='Payment method (optional)')
|
||||||
|
parser.add_argument('--output-dir', default=os.path.expanduser('~/fjccv-receipts'),
|
||||||
|
help='Output directory for PDF (default: ~/fjccv-receipts)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Determine base directory (where this script lives)
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
base_dir = script_dir.parent # Go up to skill root
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_dir = Path(args.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename (donation number only, no donor name)
|
||||||
|
filename = f"FJCCV_Receipt_{args.donation_number}.pdf"
|
||||||
|
output_path = output_dir / filename
|
||||||
|
|
||||||
|
# Generate receipt
|
||||||
|
try:
|
||||||
|
success = generate_receipt(
|
||||||
|
donor=args.donor,
|
||||||
|
amount=args.amount,
|
||||||
|
date_str=args.date,
|
||||||
|
donation_number=args.donation_number,
|
||||||
|
output_path=output_path,
|
||||||
|
transaction=args.transaction,
|
||||||
|
payment_method=args.payment_method,
|
||||||
|
base_dir=base_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"✅ Receipt generated successfully:")
|
||||||
|
print(f" {output_path}")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("❌ Failed to generate receipt")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error generating receipt: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Executable
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FJCCV Ledger Helper
|
||||||
|
Analyze the ledger and help with common tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
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 get_next_receipt_number(ledger_path: Path, donor_name: str, year: int) -> str:
|
||||||
|
"""
|
||||||
|
Determine the next receipt number for a donor.
|
||||||
|
Returns format: LDDDDDD-YYYY
|
||||||
|
"""
|
||||||
|
# Load donations sheet for the year
|
||||||
|
wb = openpyxl.load_workbook(ledger_path, data_only=True)
|
||||||
|
sheet_name = f"Donations {year}"
|
||||||
|
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
print(f"Warning: Sheet '{sheet_name}' not found. Available sheets: {wb.sheetnames}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
|
||||||
|
# Parse donor name to get last name
|
||||||
|
name_parts = donor_name.strip().split()
|
||||||
|
last_name = name_parts[-1] if name_parts else donor_name
|
||||||
|
last_initial = last_name[0].upper()
|
||||||
|
|
||||||
|
# Find all existing donations from this donor
|
||||||
|
donor_donations = []
|
||||||
|
donors_with_same_initial = set()
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
date_val = row[0]
|
||||||
|
donor_val = row[1]
|
||||||
|
receipt_num = row[5]
|
||||||
|
|
||||||
|
# Skip empty rows and totals
|
||||||
|
if not date_val or not donor_val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
donor_last_name = donor_val.strip().split()[-1] if donor_val else ""
|
||||||
|
|
||||||
|
# Track donors with same initial
|
||||||
|
if donor_last_name and donor_last_name[0].upper() == last_initial:
|
||||||
|
donors_with_same_initial.add(donor_val.strip())
|
||||||
|
|
||||||
|
# Track this specific donor's donations
|
||||||
|
if donor_val.strip().lower() == donor_name.strip().lower():
|
||||||
|
donor_donations.append((date_val, receipt_num))
|
||||||
|
|
||||||
|
# Determine donor ID (sequence among donors with same initial)
|
||||||
|
sorted_donors = sorted(donors_with_same_initial)
|
||||||
|
try:
|
||||||
|
donor_index = sorted_donors.index(donor_name.strip())
|
||||||
|
donor_id = donor_index + 1
|
||||||
|
except ValueError:
|
||||||
|
# New donor with this initial
|
||||||
|
donor_id = len(sorted_donors) + 1
|
||||||
|
|
||||||
|
# Determine donation count for this donor
|
||||||
|
donation_count = len(donor_donations) + 1
|
||||||
|
|
||||||
|
# Format receipt number: LDDDDDD-YYYY
|
||||||
|
receipt_number = f"{last_initial}{donor_id:03d}{donation_count:03d}-{year}"
|
||||||
|
|
||||||
|
return receipt_number, donor_id, donation_count, len(donors_with_same_initial)
|
||||||
|
|
||||||
|
def list_donors(ledger_path: Path, year: int = None):
|
||||||
|
"""List all donors with their donation counts"""
|
||||||
|
wb = openpyxl.load_workbook(ledger_path, data_only=True)
|
||||||
|
|
||||||
|
# Get all donation sheets
|
||||||
|
donation_sheets = [s for s in wb.sheetnames if s.startswith("Donations ")]
|
||||||
|
if year:
|
||||||
|
donation_sheets = [f"Donations {year}"]
|
||||||
|
|
||||||
|
donor_stats = {}
|
||||||
|
|
||||||
|
for sheet_name in donation_sheets:
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
sheet_year = sheet_name.split()[-1]
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
date_val = row[0]
|
||||||
|
donor_val = row[1]
|
||||||
|
amount_val = row[2]
|
||||||
|
receipt_num = row[5]
|
||||||
|
|
||||||
|
if not date_val or not donor_val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
donor_key = donor_val.strip()
|
||||||
|
if donor_key not in donor_stats:
|
||||||
|
donor_stats[donor_key] = {
|
||||||
|
'total_donations': 0,
|
||||||
|
'total_amount': 0,
|
||||||
|
'last_receipt': None,
|
||||||
|
'years': set()
|
||||||
|
}
|
||||||
|
|
||||||
|
donor_stats[donor_key]['total_donations'] += 1
|
||||||
|
donor_stats[donor_key]['total_amount'] += float(amount_val) if amount_val else 0
|
||||||
|
donor_stats[donor_key]['last_receipt'] = receipt_num
|
||||||
|
donor_stats[donor_key]['years'].add(sheet_year)
|
||||||
|
|
||||||
|
print("=== FJCCV Donor Summary ===\n")
|
||||||
|
for donor in sorted(donor_stats.keys()):
|
||||||
|
stats = donor_stats[donor]
|
||||||
|
years_str = ", ".join(sorted(stats['years']))
|
||||||
|
print(f"{donor}:")
|
||||||
|
print(f" Donations: {stats['total_donations']}")
|
||||||
|
print(f" Total Amount: ${stats['total_amount']:,.2f}")
|
||||||
|
print(f" Last Receipt: {stats['last_receipt']}")
|
||||||
|
print(f" Active Years: {years_str}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def show_recent_donations(ledger_path: Path, limit: int = 10):
|
||||||
|
"""Show most recent donations across all years"""
|
||||||
|
wb = openpyxl.load_workbook(ledger_path, data_only=True)
|
||||||
|
donation_sheets = [s for s in wb.sheetnames if s.startswith("Donations ")]
|
||||||
|
|
||||||
|
all_donations = []
|
||||||
|
|
||||||
|
for sheet_name in donation_sheets:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
date_val = row[0]
|
||||||
|
donor_val = row[1]
|
||||||
|
amount_val = row[2]
|
||||||
|
receipt_num = row[5]
|
||||||
|
|
||||||
|
if not date_val or not donor_val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_donations.append({
|
||||||
|
'date': date_val,
|
||||||
|
'donor': donor_val,
|
||||||
|
'amount': amount_val,
|
||||||
|
'receipt': receipt_num
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by date descending
|
||||||
|
all_donations.sort(key=lambda x: x['date'], reverse=True)
|
||||||
|
|
||||||
|
print(f"=== Last {limit} Donations ===\n")
|
||||||
|
for donation in all_donations[:limit]:
|
||||||
|
date_str = donation['date'].strftime('%Y-%m-%d') if hasattr(donation['date'], 'strftime') else str(donation['date'])
|
||||||
|
print(f"{date_str} | {donation['donor']:20s} | ${donation['amount']:>8,.2f} | {donation['receipt']}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='FJCCV Ledger Helper')
|
||||||
|
parser.add_argument('action', choices=['next-receipt', 'list-donors', 'recent'],
|
||||||
|
help='Action to perform')
|
||||||
|
parser.add_argument('--donor', help='Donor name (for next-receipt)')
|
||||||
|
parser.add_argument('--year', type=int, default=datetime.now().year,
|
||||||
|
help='Year (default: current year)')
|
||||||
|
parser.add_argument('--limit', type=int, default=10,
|
||||||
|
help='Limit for recent donations (default: 10)')
|
||||||
|
|
||||||
|
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.action == 'next-receipt':
|
||||||
|
if not args.donor:
|
||||||
|
print("Error: --donor required for next-receipt action")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
result = get_next_receipt_number(ledger_path, args.donor, args.year)
|
||||||
|
if result:
|
||||||
|
receipt_num, donor_id, donation_count, total_with_initial = result
|
||||||
|
print(f"Next receipt number for {args.donor} in {args.year}:")
|
||||||
|
print(f" {receipt_num}")
|
||||||
|
print(f"\nBreakdown:")
|
||||||
|
print(f" Donor ID: {donor_id} (among {total_with_initial} donors with same initial)")
|
||||||
|
print(f" Donation count: {donation_count}")
|
||||||
|
|
||||||
|
elif args.action == 'list-donors':
|
||||||
|
list_donors(ledger_path, args.year if args.year != datetime.now().year else None)
|
||||||
|
|
||||||
|
elif args.action == 'recent':
|
||||||
|
show_recent_donations(ledger_path, args.limit)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Executable
+206
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FJCCV Donation Processor
|
||||||
|
Complete workflow: calculate receipt #, generate PDF, update ledger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def get_script_dir():
|
||||||
|
return Path(__file__).parent
|
||||||
|
|
||||||
|
def get_skill_dir():
|
||||||
|
return Path(__file__).parent.parent
|
||||||
|
|
||||||
|
def process_donation(donor: str, amount: float, date_str: str,
|
||||||
|
payment_method: str, transaction_id: str = None,
|
||||||
|
update_bank: bool = True, output_dir: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
Complete donation processing workflow.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
- success: bool
|
||||||
|
- receipt_number: str
|
||||||
|
- receipt_pdf: Path
|
||||||
|
- ledger_updated: bool
|
||||||
|
- bank_updated: bool
|
||||||
|
- messages: list of str
|
||||||
|
"""
|
||||||
|
|
||||||
|
script_dir = get_script_dir()
|
||||||
|
skill_dir = get_skill_dir()
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Parse date to get year
|
||||||
|
try:
|
||||||
|
if '/' in date_str:
|
||||||
|
parts = date_str.split('/')
|
||||||
|
if len(parts[2]) == 2:
|
||||||
|
year = 2000 + int(parts[2])
|
||||||
|
else:
|
||||||
|
year = int(parts[2])
|
||||||
|
else:
|
||||||
|
year = int(date_str.split('-')[0])
|
||||||
|
except:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'messages': [f"Invalid date format: {date_str}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Get receipt number
|
||||||
|
messages.append("📋 Calculating receipt number...")
|
||||||
|
cmd = [
|
||||||
|
'python3', str(script_dir / 'ledger_helper.py'),
|
||||||
|
'next-receipt',
|
||||||
|
'--donor', donor,
|
||||||
|
'--year', str(year)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
# Parse receipt number from output (look for pattern like " LDDDDDD-YYYY")
|
||||||
|
receipt_number = None
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
stripped = line.strip()
|
||||||
|
# Look for lines with format: LDDDDDD-YYYY (letter, 6 digits, dash, 4 digits)
|
||||||
|
if stripped and len(stripped) > 8 and '-' in stripped:
|
||||||
|
parts = stripped.split('-')
|
||||||
|
if len(parts) == 2 and parts[0][0].isalpha() and parts[1].isdigit():
|
||||||
|
receipt_number = stripped
|
||||||
|
messages.append(f" Receipt #: {receipt_number}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not receipt_number:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'messages': messages + ["Could not parse receipt number from output", result.stdout]
|
||||||
|
}
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'messages': messages + [f"Error calculating receipt number: {e.stderr}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Generate PDF receipt
|
||||||
|
messages.append("\n📄 Generating PDF receipt...")
|
||||||
|
|
||||||
|
if not output_dir:
|
||||||
|
output_dir = str(Path.home() / 'fjccv-receipts')
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'python3', str(script_dir / 'generate_receipt.py'),
|
||||||
|
'--donor', donor,
|
||||||
|
'--amount', str(amount),
|
||||||
|
'--date', date_str,
|
||||||
|
'--donation-number', receipt_number,
|
||||||
|
'--payment-method', payment_method,
|
||||||
|
'--output-dir', output_dir
|
||||||
|
]
|
||||||
|
|
||||||
|
if transaction_id:
|
||||||
|
cmd.extend(['--transaction', transaction_id])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
# Extract PDF path from output
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.strip().startswith('/') and line.endswith('.pdf'):
|
||||||
|
receipt_pdf = Path(line.strip())
|
||||||
|
messages.append(f" PDF: {receipt_pdf}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
receipt_pdf = None
|
||||||
|
messages.append(" ⚠️ PDF generated but path not captured")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'receipt_number': receipt_number,
|
||||||
|
'messages': messages + [f"Error generating PDF: {e.stderr}"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Update ledger
|
||||||
|
messages.append("\n💾 Updating ledger...")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'python3', str(script_dir / 'update_ledger.py'),
|
||||||
|
'--date', date_str,
|
||||||
|
'--donor', donor,
|
||||||
|
'--amount', str(amount),
|
||||||
|
'--payment-method', payment_method
|
||||||
|
]
|
||||||
|
|
||||||
|
if transaction_id:
|
||||||
|
cmd.extend(['--transaction', transaction_id])
|
||||||
|
|
||||||
|
if update_bank:
|
||||||
|
cmd.append('--bank-reconciliation')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
messages.append(" ✅ Ledger updated")
|
||||||
|
if update_bank:
|
||||||
|
messages.append(" ✅ Bank reconciliation updated")
|
||||||
|
ledger_updated = True
|
||||||
|
bank_updated = update_bank
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
messages.append(f" ❌ Ledger update failed: {e.stderr}")
|
||||||
|
ledger_updated = False
|
||||||
|
bank_updated = False
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
messages.append("\n" + "="*50)
|
||||||
|
messages.append("✅ DONATION PROCESSED SUCCESSFULLY")
|
||||||
|
messages.append(f" Donor: {donor}")
|
||||||
|
messages.append(f" Amount: ${amount:,.2f}")
|
||||||
|
messages.append(f" Date: {date_str}")
|
||||||
|
messages.append(f" Receipt: {receipt_number}")
|
||||||
|
if receipt_pdf:
|
||||||
|
messages.append(f" PDF: {receipt_pdf.name}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'receipt_number': receipt_number,
|
||||||
|
'receipt_pdf': receipt_pdf,
|
||||||
|
'ledger_updated': ledger_updated,
|
||||||
|
'bank_updated': bank_updated,
|
||||||
|
'messages': messages
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Process FJCCV donation: receipt + ledger update',
|
||||||
|
epilog='Example: --donor "Svyatoslav Burik" --amount 500 --date "03/18/26" --payment-method Stripe --transaction pi_abc123'
|
||||||
|
)
|
||||||
|
parser.add_argument('--donor', required=True, help='Full donor name')
|
||||||
|
parser.add_argument('--amount', required=True, type=float, help='Gross donation amount')
|
||||||
|
parser.add_argument('--date', required=True, help='Date (MM/DD/YY or YYYY-MM-DD)')
|
||||||
|
parser.add_argument('--payment-method', required=True, help='Payment method')
|
||||||
|
parser.add_argument('--transaction', help='Transaction ID (optional)')
|
||||||
|
parser.add_argument('--output-dir', help='PDF output directory (default: ~/fjccv-receipts)')
|
||||||
|
parser.add_argument('--skip-bank', action='store_true', help='Skip bank reconciliation update')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = process_donation(
|
||||||
|
donor=args.donor,
|
||||||
|
amount=args.amount,
|
||||||
|
date_str=args.date,
|
||||||
|
payment_method=args.payment_method,
|
||||||
|
transaction_id=args.transaction,
|
||||||
|
update_bank=not args.skip_bank,
|
||||||
|
output_dir=args.output_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print all messages
|
||||||
|
for msg in result['messages']:
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
return 0 if result['success'] else 1
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
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