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 && (