diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index 50e9becb3b..ae730aa81c 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -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: diff --git a/autogpt_platform/backend/backend/data/credit_subscription_test.py b/autogpt_platform/backend/backend/data/credit_subscription_test.py index 63e26560b6..5192cda04e 100644 --- a/autogpt_platform/backend/backend/data/credit_subscription_test.py +++ b/autogpt_platform/backend/backend/data/credit_subscription_test.py @@ -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)