fix(backend): add invoice_id idempotency key for subscription payment failure handler

Pass the Stripe invoice ID as transaction_key to _add_transaction in
handle_subscription_payment_failure. This prevents double-charging user
credits when Stripe retries the invoice.payment_failed webhook after a
transient failure (e.g. if stripe.Invoice.pay raises a network error).

_add_transaction silently skips insertion when the key already exists,
so subsequent retries deduct nothing while still attempting to mark the
invoice as paid on the Stripe side.

Adds test to verify the idempotency key is set correctly.
This commit is contained in:
majdyz
2026-04-15 00:58:25 +07:00
parent fdfda78bc8
commit 5ba14e1152
2 changed files with 31 additions and 0 deletions

View File

@@ -1716,6 +1716,9 @@ async def handle_subscription_payment_failure(invoice: dict) -> None:
amount=-amount_due,
transaction_type=CreditTransactionType.SUBSCRIPTION,
fail_insufficient_credits=True,
# Use invoice_id as the idempotency key so that Stripe webhook retries
# (e.g. on a transient stripe.Invoice.pay failure) do not double-charge.
transaction_key=invoice_id or None,
metadata=SafeJson(
{
"stripe_customer_id": customer_id,

View File

@@ -988,3 +988,31 @@ async def test_handle_subscription_payment_failure_invoice_pay_error_does_not_ra
):
# Must not raise — the pay failure is only logged as a warning
await handle_subscription_payment_failure(invoice)
@pytest.mark.asyncio
async def test_handle_subscription_payment_failure_passes_invoice_id_as_transaction_key():
"""invoice_id is used as the idempotency key to prevent double-charging on webhook retries."""
mock_user = _make_user(user_id="user-1", tier=SubscriptionTier.PRO)
invoice = {
"id": "in_idempotency_test",
"customer": "cus_123",
"subscription": "sub_abc123",
"amount_due": 2000,
}
with (
patch(
"backend.data.credit.User.prisma",
return_value=MagicMock(find_first=AsyncMock(return_value=mock_user)),
),
patch(
"backend.data.credit.UserCredit._add_transaction",
new_callable=AsyncMock,
) as mock_add_tx,
patch("backend.data.credit.stripe.Invoice.pay"),
):
await handle_subscription_payment_failure(invoice)
mock_add_tx.assert_called_once()
_, kwargs = mock_add_tx.call_args
assert kwargs.get("transaction_key") == "in_idempotency_test"