mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user