From bfd1e6e793aba67545f276cd32d9ef6d2dab1445 Mon Sep 17 00:00:00 2001
From: majdyz
Date: Tue, 14 Apr 2026 22:41:07 +0700
Subject: [PATCH] fix(backend): fix Stripe price ID LD flag lookup and
subscription payment handling
- Use user_id="system" for global LD flag lookups (price IDs don't need user context)
- Skip Supabase lookup silently for non-UUID keys in _fetch_user_context_data
- Block paid tier changes when ENABLE_PLATFORM_PAYMENT is disabled
- Add invoice.payment_failed handler: deduct from balance or downgrade to FREE
- Hide upgrade/downgrade buttons in UI when payment flag is disabled
---
.../api/features/subscription_routes_test.py | 38 ++++++++
.../backend/backend/api/features/v1.py | 4 +
.../backend/backend/data/credit.py | 94 ++++++++++++++++++-
.../backend/backend/util/feature_flag.py | 7 ++
.../SubscriptionTierSection.tsx | 5 +-
.../SubscriptionTierSection.test.tsx | 21 ++++-
.../useSubscriptionTierSection.ts | 3 +
7 files changed, 168 insertions(+), 4 deletions(-)
diff --git a/autogpt_platform/backend/backend/api/features/subscription_routes_test.py b/autogpt_platform/backend/backend/api/features/subscription_routes_test.py
index 7a69f92fd9..6a8d8c1cf0 100644
--- a/autogpt_platform/backend/backend/api/features/subscription_routes_test.py
+++ b/autogpt_platform/backend/backend/api/features/subscription_routes_test.py
@@ -565,3 +565,41 @@ def test_stripe_webhook_dispatches_subscription_events(
assert response.status_code == 200
sync_mock.assert_awaited_once_with(stripe_sub_obj)
+
+
+def test_stripe_webhook_dispatches_invoice_payment_failed(
+ client: fastapi.testclient.TestClient,
+ mocker: pytest_mock.MockFixture,
+) -> None:
+ """POST /credits/stripe_webhook routes invoice.payment_failed to the failure handler."""
+ invoice_obj = {
+ "customer": "cus_test",
+ "subscription": "sub_test",
+ "amount_due": 1999,
+ }
+ event = {
+ "type": "invoice.payment_failed",
+ "data": {"object": invoice_obj},
+ }
+
+ mocker.patch(
+ "backend.api.features.v1.settings.secrets.stripe_webhook_secret",
+ new="whsec_test",
+ )
+ mocker.patch(
+ "backend.api.features.v1.stripe.Webhook.construct_event",
+ return_value=event,
+ )
+ failure_mock = mocker.patch(
+ "backend.api.features.v1.handle_subscription_payment_failure",
+ new_callable=AsyncMock,
+ )
+
+ response = client.post(
+ "/credits/stripe_webhook",
+ content=b"{}",
+ headers={"stripe-signature": "t=1,v1=abc"},
+ )
+
+ assert response.status_code == 200
+ failure_mock.assert_awaited_once_with(invoice_obj)
diff --git a/autogpt_platform/backend/backend/api/features/v1.py b/autogpt_platform/backend/backend/api/features/v1.py
index 50ab711bdd..e65547b739 100644
--- a/autogpt_platform/backend/backend/api/features/v1.py
+++ b/autogpt_platform/backend/backend/api/features/v1.py
@@ -57,6 +57,7 @@ from backend.data.credit import (
get_auto_top_up,
get_subscription_price_id,
get_user_credit_model,
+ handle_subscription_payment_failure,
set_auto_top_up,
set_subscription_tier,
sync_subscription_from_stripe,
@@ -973,6 +974,9 @@ async def stripe_webhook(request: Request):
):
await sync_subscription_from_stripe(data_object)
+ if event_type == "invoice.payment_failed":
+ await handle_subscription_payment_failure(data_object)
+
# `handle_dispute` and `deduct_credits` expect Stripe SDK typed objects
# (Dispute/Refund). The Stripe webhook payload's `data.object` is a
# StripeObject (a dict subclass) carrying that runtime shape, so we cast
diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py
index 9f24be3d32..b4f6cfb4af 100644
--- a/autogpt_platform/backend/backend/data/credit.py
+++ b/autogpt_platform/backend/backend/data/credit.py
@@ -1368,7 +1368,7 @@ async def get_subscription_price_id(tier: SubscriptionTier) -> str | None:
flag = flag_map.get(tier)
if flag is None:
return None
- price_id = await get_feature_flag_value(flag.value, user_id="", default="")
+ price_id = await get_feature_flag_value(flag.value, user_id="system", default="")
return price_id if isinstance(price_id, str) and price_id else None
@@ -1599,6 +1599,98 @@ async def sync_subscription_from_stripe(stripe_subscription: dict) -> None:
await set_subscription_tier(user.id, tier)
+async def handle_subscription_payment_failure(invoice: dict) -> None:
+ """Handle a failed Stripe subscription payment.
+
+ Tries to cover the invoice amount from the user's credit balance.
+ Either way the Stripe subscription is cancelled so Stripe stops retrying.
+
+ - Balance sufficient → deduct, cancel Stripe sub, keep tier.
+ - Balance insufficient → cancel Stripe sub, downgrade to FREE immediately.
+ """
+ customer_id = invoice.get("customer")
+ if not customer_id:
+ logger.warning(
+ "handle_subscription_payment_failure: missing customer in invoice; skipping"
+ )
+ return
+
+ user = await User.prisma().find_first(where={"stripeCustomerId": customer_id})
+ if not user:
+ logger.warning(
+ "handle_subscription_payment_failure: no user found for customer %s",
+ customer_id,
+ )
+ return
+
+ current_tier = user.subscriptionTier or SubscriptionTier.FREE
+ if current_tier == SubscriptionTier.ENTERPRISE:
+ logger.warning(
+ "handle_subscription_payment_failure: skipping ENTERPRISE user %s"
+ " (customer %s) — tier is admin-managed",
+ user.id,
+ customer_id,
+ )
+ return
+
+ amount_due: int = invoice.get("amount_due", 0)
+ sub_id: str = invoice.get("subscription", "")
+
+ if amount_due <= 0:
+ logger.info(
+ "handle_subscription_payment_failure: amount_due=%d for user %s;"
+ " nothing to deduct",
+ amount_due,
+ user.id,
+ )
+ return
+
+ credit_model = UserCredit()
+ try:
+ await credit_model._add_transaction(
+ user_id=user.id,
+ amount=-amount_due,
+ transaction_type=CreditTransactionType.SUBSCRIPTION,
+ fail_insufficient_credits=True,
+ metadata=SafeJson(
+ {
+ "stripe_customer_id": customer_id,
+ "stripe_subscription_id": sub_id,
+ "reason": "subscription_payment_failure_covered_by_balance",
+ }
+ ),
+ )
+ logger.info(
+ "handle_subscription_payment_failure: deducted %d cents from balance"
+ " for user %s; cancelling Stripe sub %s to prevent further retries",
+ amount_due,
+ user.id,
+ sub_id,
+ )
+ except InsufficientBalanceError:
+ logger.info(
+ "handle_subscription_payment_failure: insufficient balance for user %s;"
+ " downgrading to FREE and cancelling Stripe sub %s",
+ user.id,
+ sub_id,
+ )
+ await set_subscription_tier(user.id, SubscriptionTier.FREE)
+
+ # Cancel the Stripe subscription regardless — if balance covered it we don't
+ # want Stripe to retry next month; if balance was insufficient the user is
+ # already downgraded and the sub must go.
+ try:
+ await _cancel_customer_subscriptions(customer_id)
+ except stripe.StripeError:
+ logger.warning(
+ "handle_subscription_payment_failure: failed to cancel Stripe sub %s"
+ " for user %s (customer %s); Stripe may continue retrying",
+ sub_id,
+ user.id,
+ customer_id,
+ )
+
+
async def admin_get_user_history(
page: int = 1,
page_size: int = 20,
diff --git a/autogpt_platform/backend/backend/util/feature_flag.py b/autogpt_platform/backend/backend/util/feature_flag.py
index 27121304ca..c341666cdb 100644
--- a/autogpt_platform/backend/backend/util/feature_flag.py
+++ b/autogpt_platform/backend/backend/util/feature_flag.py
@@ -1,6 +1,7 @@
import contextlib
import logging
import os
+import uuid
from enum import Enum
from functools import wraps
from typing import Any, Awaitable, Callable, TypeVar
@@ -101,6 +102,12 @@ async def _fetch_user_context_data(user_id: str) -> Context:
"""
builder = Context.builder(user_id).kind("user").anonymous(True)
+ try:
+ uuid.UUID(user_id)
+ except ValueError:
+ # Non-UUID key (e.g. "system") — skip Supabase lookup, return anonymous context.
+ return builder.build()
+
try:
from backend.util.clients import get_supabase
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx
index b04a1de497..2d223aaeed 100644
--- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx
@@ -49,6 +49,7 @@ export function SubscriptionTierSection() {
tierError,
isPending,
pendingTier,
+ isPaymentEnabled,
changeTier,
handleTierChange,
} = useSubscriptionTierSection();
@@ -163,7 +164,7 @@ export function SubscriptionTierSection() {
{tier.description}
- {!isCurrent && (
+ {!isCurrent && isPaymentEnabled && (