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
+5
View File
@@ -0,0 +1,5 @@
.env
*.pyc
__pycache__/
*.log
*.xlsx~
+264
View File
@@ -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())
+212
View File
@@ -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())
+206
View File
@@ -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())
+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())