From 8fb646836972790b43d1008ddaf1c4df10a76e1c Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 25 May 2026 02:32:57 -0700 Subject: [PATCH] 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 --- .gitignore | 5 + generate_receipt.py | 264 ++++++++++++++++++++++++++++++++++++++ ledger_helper.py | 212 ++++++++++++++++++++++++++++++ process_donation.py | 206 ++++++++++++++++++++++++++++++ update_ledger.py | 305 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 .gitignore create mode 100755 generate_receipt.py create mode 100755 ledger_helper.py create mode 100755 process_donation.py create mode 100755 update_ledger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb3a7f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.pyc +__pycache__/ +*.log +*.xlsx~ diff --git a/generate_receipt.py b/generate_receipt.py new file mode 100755 index 0000000..0f3cec4 --- /dev/null +++ b/generate_receipt.py @@ -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()) diff --git a/ledger_helper.py b/ledger_helper.py new file mode 100755 index 0000000..3f3c1c7 --- /dev/null +++ b/ledger_helper.py @@ -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()) diff --git a/process_donation.py b/process_donation.py new file mode 100755 index 0000000..196e441 --- /dev/null +++ b/process_donation.py @@ -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()) diff --git a/update_ledger.py b/update_ledger.py new file mode 100755 index 0000000..dcf4456 --- /dev/null +++ b/update_ledger.py @@ -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())