Add Stripe event deduplication to prevent duplicate notifications

- 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.
This commit is contained in:
Krystie
2026-05-25 17:13:29 -07:00
parent 7328d49d81
commit 62ccbbe93d
+53 -3
View File
@@ -30,6 +30,46 @@ from update_ledger import add_donation as ledger_add_donation
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')
@@ -287,7 +327,13 @@ def stripe_webhook():
abort(400, 'Invalid JSON')
event_type = event.get('type', '')
app.logger.info(f"Received event: {event_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':
@@ -322,7 +368,7 @@ def stripe_webhook():
# 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)
args=(session, event_id, donor_name, donor_email, amount, transaction_id, date_str, year)
)
thread.start()
@@ -332,9 +378,13 @@ def stripe_webhook():
return jsonify({'received': True})
def _process_checkout_session(session, donor_name, donor_email, amount, transaction_id, date_str, year):
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