commit cbb839c3b542652034424091e18e0106f7acb871 Author: Krystie Date: Mon May 25 02:27:21 2026 -0700 Initial commit: FJCCV Stripe webhook server - Handles checkout.session.completed events from Stripe - Auto-generates PDF donation receipts - Uploads receipts to Sami-PC and Dropbox - Updates Excel ledger automatically - Sends receipt emails automatically (AUTO_EMAIL=true) - Creates approval queue entries for donation tracking diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83a921e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.pyc +__pycache__/ diff --git a/fjccv_webhook.py b/fjccv_webhook.py new file mode 100644 index 0000000..8f55a56 --- /dev/null +++ b/fjccv_webhook.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +""" +FJCCV Stripe Webhook Server +Listens for Stripe checkout.session.completed events and auto-generates donation receipts. +""" + +import os +import sys +import json +import hmac +import hashlib +import subprocess +import uuid +import smtplib +import ssl +import threading +from datetime import datetime +from pathlib import Path +from flask import Flask, request, jsonify, abort +from email.message import EmailMessage + +# Add scripts directory to path for ledger imports +SCRIPT_DIR = Path(__file__).parent.parent / 'scripts' +sys.path.insert(0, str(SCRIPT_DIR)) + +# Import ledger functions +from update_ledger import add_donation as ledger_add_donation + +# Configuration +STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY', '') +WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', '') # whsec_... when Stripe is configured +OUTPUT_DIR = os.environ.get('FJCCV_RECEIPTS_DIR', '/root/fjccv-receipts') + +# Sami's PC for receipt delivery +SAMIPC_HOST = os.environ.get('SAMIPC_HOST', 'hello@100.85.236.10') +SAMIPC_KEY = os.environ.get('SAMIPC_SSH_KEY', '/root/.ssh/krystie_to_sami_pc') +SAMIPC_RECEIPTS_PATH = os.environ.get('SAMIPC_RECEIPTS_PATH', 'E:\\church\\donation receipts') +USE_SAMIPC = os.environ.get('USE_SAMIPC', 'false').lower() == 'true' + +# Telegram notification settings +TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') +TELEGRAM_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '637130179') # Sami's Telegram ID +APPROVAL_QUEUE_DIR = os.environ.get('APPROVAL_QUEUE_DIR', '/root/fjccv-approval-queue') + +# SMTP settings for sending receipt emails +SMTP_HOST = os.environ.get('SMTP_HOST', '') +SMTP_PORT = int(os.environ.get('SMTP_PORT', 465)) +SMTP_USER = os.environ.get('SMTP_USER', '') +SMTP_PASS = os.environ.get('SMTP_PASS', '') +SMTP_FROM = os.environ.get('SMTP_FROM', 'FJCCV ') + +# Auto-email receipts (set to false to require manual approval first) +AUTO_EMAIL = os.environ.get('AUTO_EMAIL', 'false').lower() == 'true' + +app = Flask(__name__) + +os.makedirs(APPROVAL_QUEUE_DIR, exist_ok=True) + +def verify_stripe_signature(payload: bytes, signature: str, secret: str) -> bool: + """Verify the Stripe webhook signature.""" + if not secret: + app.logger.warning("No webhook secret configured — skipping signature verification") + return True + + try: + # Stripe signature format: t=timestamp,v1=signature + parts = dict(item.split('=') for item in signature.split(',')) + timestamp = parts.get('t', '') + sig = parts.get('v1', '') + + if not timestamp or not sig: + return False + + # Compute expected signature + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + expected = hmac.new( + secret.encode('utf-8'), + signed_payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(expected, sig) + except Exception as e: + app.logger.error(f"Signature verification error: {e}") + return False + +def get_donation_number(donor_name: str, year: int) -> str: + """Get the next donation number for a donor.""" + # Scripts are in the parent skill directory, not inside webhook-server/ + script_dir = Path('/root/.openclaw/agents/main/workspace/skills/fjccv-donation-receipts/scripts') + cmd = [ + 'python3', str(script_dir / 'ledger_helper.py'), + 'next-receipt', '--donor', donor_name, '--year', str(year) + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + for line in result.stdout.split('\n'): + stripped = line.strip() + if stripped and '-' in stripped and len(stripped) > 8: + parts = stripped.split('-') + if len(parts) == 2 and parts[0][0].isalpha() and parts[1].isdigit(): + return stripped + except subprocess.CalledProcessError as e: + app.logger.error(f"Error getting donation number: {e.stderr}") + + return None + +def generate_receipt(donor: str, amount: float, date_str: str, + donation_number: str, transaction_id: str = None) -> Path: + """Generate the PDF receipt using the existing script.""" + # Scripts are in the parent skill directory + script_dir = Path('/root/.openclaw/agents/main/workspace/skills/fjccv-donation-receipts/scripts') + output_dir = Path(OUTPUT_DIR) + output_dir.mkdir(parents=True, exist_ok=True) + + cmd = [ + 'python3', str(script_dir / 'generate_receipt.py'), + '--donor', donor, + '--amount', str(amount), + '--date', date_str, + '--donation-number', donation_number, + '--output-dir', str(output_dir) + ] + + if transaction_id: + cmd.extend(['--transaction', transaction_id]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + for line in result.stdout.split('\n'): + if '.pdf' in line: + return Path(line.strip()) + except subprocess.CalledProcessError as e: + app.logger.error(f"Error generating receipt: {e.stderr}") + + return None + +def upload_to_samipc(pdf_path: Path) -> bool: + """Upload the receipt to Sami's PC via SCP.""" + # Ensure the directory exists on the remote PC + mkdir_cmd = ['ssh', '-i', SAMIPC_KEY, + '-o', 'StrictHostKeyChecking=no', + SAMIPC_HOST, + f'if not exist "{SAMIPC_RECEIPTS_PATH}" mkdir "{SAMIPC_RECEIPTS_PATH}"'] + + try: + subprocess.run(mkdir_cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + app.logger.warning(f"Could not create remote directory (may already exist): {e.stderr}") + + # SCP the file to Sami's PC + scp_cmd = ['scp', '-i', SAMIPC_KEY, + '-o', 'StrictHostKeyChecking=no', + str(pdf_path), + f'{SAMIPC_HOST}:"{SAMIPC_RECEIPTS_PATH}\\\\{pdf_path.name}"'] + + try: + result = subprocess.run(scp_cmd, capture_output=True, text=True, check=True) + app.logger.info(f"Uploaded to Sami-pc: {SAMIPC_RECEIPTS_PATH}\\{pdf_path.name}") + return True + except subprocess.CalledProcessError as e: + app.logger.error(f"Error uploading to Sami-pc: {e.stderr}") + return False + +def upload_to_dropbox(pdf_path: Path) -> bool: + """Upload the receipt to Dropbox.""" + dest = f"/Krystie/fjccv/{pdf_path.name}" + cmd = ['dbxcli', 'put', str(pdf_path), dest] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + app.logger.info(f"Uploaded to Dropbox: {dest}") + return True + except subprocess.CalledProcessError as e: + app.logger.error(f"Error uploading to Dropbox: {e.stderr}") + return False + +def send_approval_queue(pdf_path: Path, donor_name: str, donor_email: str, + amount: float, receipt_number: str, transaction_id: str) -> bool: + """Write approval request to queue file for Krystie main agent to process.""" + import urllib.request + import urllib.parse + import uuid + + queue_id = str(uuid.uuid4())[:8] + queue_file = Path(APPROVAL_QUEUE_DIR) / f"{queue_id}.json" + + queue_data = { + 'id': queue_id, + 'donor_name': donor_name, + 'donor_email': donor_email, + 'amount': amount, + 'receipt_number': receipt_number, + 'transaction_id': transaction_id, + 'pdf_path': str(pdf_path), + 'status': 'pending', + 'created_at': datetime.now().isoformat() + } + + try: + queue_file.write_text(json.dumps(queue_data, indent=2)) + app.logger.info(f"Approval queue created: {queue_file}") + + # Also try to notify via local Krystie webhook if available + try: + notify_url = 'http://localhost:18888/webhook/fjccv-donation' + data = urllib.parse.urlencode(queue_data).encode('utf-8') + req = urllib.request.Request(notify_url, data=data, method='POST') + with urllib.request.urlopen(req, timeout=5): + pass + app.logger.info("Notified Krystie main agent via local webhook") + except Exception as e: + app.logger.debug(f"Local webhook notification skipped: {e}") + + return True + except Exception as e: + app.logger.error(f"Error creating approval queue: {e}") + return False + +def email_receipt(donor_email: str, donor_name: str, amount: float, + receipt_number: str, pdf_path: Path) -> bool: + """Send the receipt PDF to the donor via email using SMTP SSL.""" + if not SMTP_HOST or not SMTP_USER or not SMTP_PASS: + app.logger.warning("SMTP not configured — skipping email") + return False + + if not donor_email: + app.logger.warning("No donor email — skipping email") + return False + + try: + msg = EmailMessage() + msg['Subject'] = f'FJCCV Donation Receipt - {receipt_number}' + msg['From'] = SMTP_FROM + msg['To'] = donor_email + + # Email body + body = f"""Dear {donor_name}, + +Thank you for your generous donation to the Fellowship of Jesus Christ in California Valley (FJCCV). + +Donation Details: +- Receipt Number: {receipt_number} +- Amount: ${amount:.2f} +- Date: {datetime.now().strftime('%m/%d/%Y')} + +Your donation receipt is attached to this email. + +Blessings, +FJCCV +""" + msg.set_content(body) + + # Attach PDF + with open(pdf_path, 'rb') as f: + pdf_data = f.read() + msg.add_attachment(pdf_data, maintype='application', subtype='pdf', + filename=pdf_path.name) + + # Send via SMTP SSL + context = ssl.create_default_context() + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server: + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + + app.logger.info(f"Receipt emailed to {donor_email}") + return True + except Exception as e: + app.logger.error(f"Error sending email: {e}") + return False + +@app.route('/webhooks/stripe', methods=['POST']) +def stripe_webhook(): + """Handle Stripe webhook events. Returns 200 immediately, processes async.""" + + # Get signature header + signature = request.headers.get('Stripe-Signature', '') + + # Get raw payload (bytes for signature verification) + payload = request.get_data() + + # Verify signature + if WEBHOOK_SECRET and not verify_stripe_signature(payload, signature, WEBHOOK_SECRET): + app.logger.warning("Invalid Stripe signature") + abort(400, 'Invalid signature') + + try: + event = json.loads(payload) + except json.JSONDecodeError as e: + app.logger.error(f"Invalid JSON payload: {e}") + abort(400, 'Invalid JSON') + + event_type = event.get('type', '') + app.logger.info(f"Received event: {event_type}") + + # Handle checkout.session.completed (Stripe Checkout Payment Links) + if event_type == 'checkout.session.completed': + session = event['data']['object'] + + # Extract donor info + customer_details = session.get('customer_details', {}) or {} + donor_name = customer_details.get('name', 'Anonymous Donor') + donor_email = customer_details.get('email', '') + amount_total = session.get('amount_total', 0) + + # Amount is in cents — convert to dollars + amount = amount_total / 100.0 + + # Transaction ID — use payment_intent if available, otherwise session ID + payment_intent = session.get('payment_intent') + transaction_id = payment_intent or session.get('id', '') + + # Date + created = session.get('created', 0) + if created: + date_str = datetime.fromtimestamp(created).strftime('%m/%d/%y') + else: + date_str = datetime.now().strftime('%m/%d/%y') + + # Year for donation number + year = datetime.fromtimestamp(created).year if created else datetime.now().year + + app.logger.info(f"Queuing donation: {donor_name}, ${amount:.2f}, {date_str}, tx: {transaction_id}") + + # Return 200 immediately - process in background thread + # This is important because Stripe requires a quick 2xx response + thread = threading.Thread( + target=_process_checkout_session, + args=(session, donor_name, donor_email, amount, transaction_id, date_str, year) + ) + thread.start() + + return jsonify({'received': True, 'status': 'processing'}) + + # Acknowledge other events + return jsonify({'received': True}) + + +def _process_checkout_session(session, donor_name, donor_email, amount, transaction_id, date_str, year): + """Background processing for checkout.session.completed events.""" + try: + app.logger.info(f"Processing donation: {donor_name}, ${amount:.2f}, {date_str}, tx: {transaction_id}") + + # Get donation number + donation_number = get_donation_number(donor_name, year) + if not donation_number: + app.logger.error("Could not determine donation number") + return + + app.logger.info(f"Donation number: {donation_number}") + + # Generate receipt + receipt_pdf = generate_receipt( + donor=donor_name, + amount=amount, + date_str=date_str, + donation_number=donation_number, + transaction_id=transaction_id, + payment_method="Stripe" + ) + + if not receipt_pdf: + app.logger.error("Failed to generate receipt") + return + + app.logger.info(f"Receipt generated: {receipt_pdf}") + + # Upload to Sami-pc or Dropbox (always do this as backup) + if USE_SAMIPC: + upload_to_samipc(receipt_pdf) + else: + upload_to_dropbox(receipt_pdf) + + # Update ledger with donation + try: + ledger_path = Path.home() / ".openclaw/agents/main/workspace/FJCCV_Ledger.xlsx" + ledger_result = ledger_add_donation( + ledger_path=ledger_path, + date_str=date_str, + donor=donor_name, + gross_amount=amount, + payment_method="Stripe", + transaction_id=transaction_id + ) + if ledger_result.get('success'): + app.logger.info(f"Ledger updated: {ledger_result.get('message')}") + else: + app.logger.error(f"Ledger update failed: {ledger_result.get('message')}") + except Exception as e: + app.logger.error(f"Error updating ledger: {e}") + + # Send to approval queue — Krystie main agent will notify via Telegram + send_approval_queue( + pdf_path=receipt_pdf, + donor_name=donor_name, + donor_email=donor_email, + amount=amount, + receipt_number=donation_number, + transaction_id=transaction_id + ) + + # Email receipt to donor (if AUTO_EMAIL is enabled) + if AUTO_EMAIL and donor_email: + email_receipt( + donor_email=donor_email, + donor_name=donor_name, + amount=amount, + receipt_number=donation_number, + pdf_path=receipt_pdf + ) + + app.logger.info(f"Processing complete for donation {donation_number}") + + except Exception as e: + app.logger.error(f"Error processing checkout session: {e}") + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({'status': 'ok'}) + +if __name__ == '__main__': + # Run with: python3 fjccv_webhook.py + # Set env vars first: + # export STRIPE_SECRET_KEY=sk_live_... + # export STRIPE_WEBHOOK_SECRET=whsec_... (optional for local testing) + # export USE_SAMIPC=true (to upload to Sami's PC instead of Dropbox) + port = int(os.environ.get('PORT', 5000)) + debug = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' + + print(f"Starting FJCCV Stripe Webhook Server on port {port}") + print(f"Stripe key configured: {'Yes' if STRIPE_SECRET_KEY else 'No'}") + print(f"Webhook secret configured: {'Yes' if WEBHOOK_SECRET else 'No (will skip verification)'}") + print(f"Upload to Sami-pc: {'Yes' if USE_SAMIPC else 'No (using Dropbox)'}") + + app.run(host='0.0.0.0', port=port, debug=debug) \ No newline at end of file