Files
fjccv-webhook/fjccv_webhook.py
T
Krystie cbb839c3b5 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
2026-05-25 02:27:21 -07:00

437 lines
16 KiB
Python

#!/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 <receipts@fjccv.org>')
# 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)