From 6083a1e53c32b891b77820c2f50e229774fecdcd Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sat, 23 May 2026 22:10:25 +0100 Subject: [PATCH] fix: stripe webhook idempotency + payment_status guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check payment_status == "paid" before granting credits — prevents granting credits for unpaid/pending checkout sessions - Idempotency guard: query credit_transactions for existing ref.stripe_payment_id before processing — Stripe retries webhooks on timeout, this prevents double credits Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/billing.py | 38 +++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/app/routes/billing.py b/backend/app/routes/billing.py index 01a1d86c..ca049ff0 100644 --- a/backend/app/routes/billing.py +++ b/backend/app/routes/billing.py @@ -95,22 +95,38 @@ async def stripe_webhook(): if event["type"] == "checkout.session.completed": session = event["data"]["object"] + + # Only process confirmed payments — never grant credits for unpaid sessions + if session.get("payment_status") != "paid": + logger.info("Skipping unpaid checkout session %s", session.get("id")) + return jsonify({"status": "ok"}), 200 + meta = session.get("metadata", {}) user_id = meta.get("user_id") credits = int(meta.get("credits", 0)) pack_id = meta.get("pack_id", "") payment_id = session.get("payment_intent", session.get("id", "")) - if user_id and credits > 0: - balance = await User.grant_credits(user_id, credits) - await CreditTransaction.record( - user_id=user_id, - tx_type="purchase", - amount=credits, - balance_after=balance, - description=f"Purchased {pack_id} pack ({credits} credits)", - ref={"stripe_payment_id": payment_id}, - ) - logger.info(f"Granted {credits} credits to user {user_id} via Stripe") + if not user_id or credits <= 0: + return jsonify({"status": "ok"}), 200 + + # Idempotency — skip if this payment_intent was already processed + from app.db import get_db as _get_db + db = await _get_db() + already = await db.credit_transactions.find_one({"ref.stripe_payment_id": payment_id}) + if already: + logger.info("Duplicate webhook for payment %s — skipping", payment_id) + return jsonify({"status": "ok"}), 200 + + balance = await User.grant_credits(user_id, credits) + await CreditTransaction.record( + user_id=user_id, + tx_type="purchase", + amount=credits, + balance_after=balance, + description=f"Purchased {pack_id} pack ({credits} credits)", + ref={"stripe_payment_id": payment_id}, + ) + logger.info("Granted %d credits to user %s via Stripe payment %s", credits, user_id, payment_id) return jsonify({"status": "ok"}), 200