62ccbbe93d
- Added PROCESSED_EVENTS_FILE to track processed Stripe event IDs
- Added is_event_processed() to check if an event was already handled
- Added mark_event_processed() to record processed events
- Webhook handler now checks for duplicate events before processing
- _process_checkout_session marks event as processed immediately
- Duplicate events now return {'status': 'duplicate'} instead of reprocessing
Fixes phantom notification bug where Stripe retry webhooks caused duplicate Telegram messages.
480 lines
18 KiB
Python
480 lines
18 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')
|
|
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 <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__)
|
|
|
|
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) |