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
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
@@ -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 <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)
|
||||
Reference in New Issue
Block a user