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