#!/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') PROCESSED_EVENTS_FILE = os.environ.get('PROCESSED_EVENTS_FILE', '/root/fjccv-receipts/processed_events.json') # Thread-safe lock for file operations import threading processed_events_lock = threading.Lock() def is_event_processed(event_id: str) -> bool: """Check if a Stripe event has already been processed.""" if not event_id: return False try: with processed_events_lock: if not os.path.exists(PROCESSED_EVENTS_FILE): return False with open(PROCESSED_EVENTS_FILE, 'r') as f: processed = json.load(f) return event_id in processed except (json.JSONDecodeError, IOError) as e: app.logger.warning(f"Error reading processed events file: {e}") return False def mark_event_processed(event_id: str) -> None: """Mark a Stripe event as processed.""" if not event_id: return try: with processed_events_lock: processed = set() if os.path.exists(PROCESSED_EVENTS_FILE): try: with open(PROCESSED_EVENTS_FILE, 'r') as f: processed = set(json.load(f)) except (json.JSONDecodeError, IOError): pass processed.add(event_id) os.makedirs(os.path.dirname(PROCESSED_EVENTS_FILE), exist_ok=True) with open(PROCESSED_EVENTS_FILE, 'w') as f: json.dump(list(processed), f) except IOError as e: app.logger.warning(f"Error writing processed events file: {e}") # 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 # 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__) 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, payment_method: 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]) if payment_method: cmd.extend(['--payment-method', payment_method]) 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_telegram_notification(donor_name: str, amount: float, receipt_number: str, transaction_id: str) -> bool: """Send Telegram notification for a new donation.""" if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: app.logger.warning("Telegram not configured — skipping notification") return False try: import urllib.request import urllib.parse message = f"💰 *New Donation Received*\n\n" message += f"*Donor:* {donor_name}\n" message += f"*Amount:* ${amount:.2f}\n" message += f"*Receipt:* {receipt_number}\n" message += f"*Transaction:* `{transaction_id}`" url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" data = urllib.parse.urlencode({ 'chat_id': TELEGRAM_CHAT_ID, 'text': message, 'parse_mode': 'Markdown' }).encode('utf-8') req = urllib.request.Request(url, data=data, method='POST') with urllib.request.urlopen(req, timeout=10) as response: result = json.loads(response.read().decode('utf-8')) if result.get('ok'): app.logger.info(f"Telegram notification sent for {donor_name}") return True else: app.logger.error(f"Telegram API error: {result}") return False except Exception as e: app.logger.error(f"Error sending Telegram notification: {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', '') event_id = event.get('id', '') app.logger.info(f"Received event: {event_type} (id: {event_id})") # Check for duplicate events if event_id and is_event_processed(event_id): app.logger.info(f"Duplicate event skipped: {event_id}") return jsonify({'received': True, 'status': 'duplicate'}) # 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, event_id, 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, event_id, donor_name, donor_email, amount, transaction_id, date_str, year): """Background processing for checkout.session.completed events.""" try: # Mark event as processed immediately to prevent duplicate processing if event_id: mark_event_processed(event_id) 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 Telegram notification for this donation send_telegram_notification( donor_name=donor_name, 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)