fix: stripe webhook idempotency + payment_status guard

- 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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-23 22:10:25 +01:00
parent b923605ea7
commit 6083a1e53c

View file

@ -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