fix(backend): pay Stripe invoice after balance covers subscription payment failure

When handle_subscription_payment_failure successfully deducts the invoice
amount from a user's credit balance, Stripe's dunning system would still
retry the failed invoice automatically on its schedule, causing repeated
deductions per billing period. Fix by calling stripe.Invoice.pay() after
the successful balance deduction so Stripe marks the invoice as settled and
stops retrying. Invoice.pay failures are logged as warnings but do not
propagate, matching the existing best-effort pattern for cleanup operations.

Add two tests covering the pay call and the swallowed-error path.
This commit is contained in:
majdyz
2026-04-15 00:42:19 +07:00
parent b681363969
commit fdfda78bc8
2 changed files with 76 additions and 7 deletions

View File

@@ -1666,9 +1666,8 @@ async def handle_subscription_payment_failure(invoice: dict) -> None:
Tries to cover the invoice amount from the user's credit balance.
- Balance sufficient → deduct from balance, leave Stripe sub intact so the
user keeps their tier. Stripe will not retry the failed invoice; the sub
continues to the next billing cycle normally.
- Balance sufficient → deduct from balance, then pay the Stripe invoice so
Stripe stops retrying it. The sub stays intact and the user keeps their tier.
- Balance insufficient → cancel Stripe sub immediately, downgrade to FREE.
Cancelling here avoids further Stripe retries on an invoice we cannot cover.
"""
@@ -1699,6 +1698,7 @@ async def handle_subscription_payment_failure(invoice: dict) -> None:
amount_due: int = invoice.get("amount_due", 0)
sub_id: str = invoice.get("subscription", "")
invoice_id: str = invoice.get("id", "")
if amount_due <= 0:
logger.info(
@@ -1724,14 +1724,25 @@ async def handle_subscription_payment_failure(invoice: dict) -> None:
}
),
)
# Balance covered the invoice — leave the Stripe subscription intact.
# Cancelling it would fire customer.subscription.deleted which would
# downgrade the user to FREE despite us having just covered the cost.
# Balance covered the invoice. Pay the Stripe invoice so Stripe's dunning
# system stops retrying it — without this call Stripe would retry automatically
# and re-trigger this webhook, causing double-deductions each retry cycle.
if invoice_id:
try:
await run_in_threadpool(stripe.Invoice.pay, invoice_id)
except stripe.StripeError:
logger.warning(
"handle_subscription_payment_failure: balance deducted for user"
" %s but failed to mark invoice %s as paid; Stripe may retry",
user.id,
invoice_id,
)
logger.info(
"handle_subscription_payment_failure: deducted %d cents from balance"
" for user %s; Stripe sub %s left intact, tier preserved",
" for user %s; Stripe invoice %s paid, sub %s intact, tier preserved",
amount_due,
user.id,
invoice_id,
sub_id,
)
except InsufficientBalanceError:

View File

@@ -12,6 +12,7 @@ from prisma.models import User
from backend.data.credit import (
cancel_stripe_subscription,
create_subscription_checkout,
handle_subscription_payment_failure,
set_subscription_tier,
sync_subscription_from_stripe,
)
@@ -930,3 +931,60 @@ async def test_sync_subscription_from_stripe_no_metadata_user_id_skips_check():
await sync_subscription_from_stripe(stripe_sub)
# No metadata → cross-check skipped → tier updated normally
mock_set.assert_awaited_once_with("user-1", SubscriptionTier.PRO)
@pytest.mark.asyncio
async def test_handle_subscription_payment_failure_balance_covers_pays_invoice():
"""When balance covers the invoice, Stripe Invoice.pay is called to stop retries."""
mock_user = _make_user(user_id="user-1", tier=SubscriptionTier.PRO)
invoice = {
"id": "in_abc123",
"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,
),
patch("backend.data.credit.stripe.Invoice.pay") as mock_pay,
):
await handle_subscription_payment_failure(invoice)
mock_pay.assert_called_once_with("in_abc123")
@pytest.mark.asyncio
async def test_handle_subscription_payment_failure_invoice_pay_error_does_not_raise():
"""Failure to mark the invoice as paid is logged but does not propagate."""
import stripe as stripe_mod
mock_user = _make_user(user_id="user-1", tier=SubscriptionTier.PRO)
invoice = {
"id": "in_abc123",
"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,
),
patch(
"backend.data.credit.stripe.Invoice.pay",
side_effect=stripe_mod.StripeError("network error"),
),
):
# Must not raise — the pay failure is only logged as a warning
await handle_subscription_payment_failure(invoice)