From 4a1741cc1532c2ad75da113e04a4ee1c36333224 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Thu, 30 Apr 2026 10:25:02 +0700 Subject: [PATCH] fix(platform): cancel-banner copy + clearer 422 on currency mismatch (#12947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Two regressions surfaced after [#12933](https://github.com/Significant-Gravitas/AutoGPT/pull/12933) merged to `dev`: 1. **Cancel-pending banner shows wrong copy.** The merged PR moved cancel-at-period-end from `BASIC` → `NO_TIER`, but `PendingChangeBanner.isCancellation` was still keyed on `"BASIC"`. As a result, a user who cancels their sub now sees *"Scheduled to downgrade to No subscription on …"* instead of the intended *"Scheduled to cancel your subscription on …"*. Caught by Sentry on the merged PR. 2. **Currency-mismatch downgrade returns 502 (looks like outage).** A user with an existing GBP-active sub (Max Price has `currency_options.gbp`) tried to downgrade to Pro and got 502. The backend logs show: ``` stripe._error.InvalidRequestError: The price specified only supports `usd`. This doesn't match the expected currency: `gbp`. ``` The Pro Price is USD-only; Stripe rejects `SubscriptionSchedule.modify` because phases must share currency. Wrapping that in a generic 502 hid the real cause and made it read like a Stripe outage. ## What * Frontend: flip `PendingChangeBanner.isCancellation` from `pendingTier === "BASIC"` to `"NO_TIER"`. Update both component and page-level tests that exercised the cancellation branch. * Backend: catch `stripe.InvalidRequestError` whose message mentions `currency` in `update_subscription_tier`, and return **422** with *"Tier change unavailable for your current billing currency. Cancel your subscription and re-subscribe at the target tier, or contact support."* — so users see the actual reason, not a misleading outage message. Other `StripeError` paths still return 502. * New backend test asserts the currency-mismatch branch returns 422 with the new copy. ## How * `PendingChangeBanner.tsx` line 28: 1-char change (`"BASIC"` → `"NO_TIER"`). * `subscription_routes_test.py` and `PendingChangeBanner.test.tsx` updated to use `NO_TIER` for the cancellation fixture. * `v1.py` `update_subscription_tier` adds a typed `except stripe.InvalidRequestError` branch ahead of the generic `StripeError`; only currency-mismatch messages get the special 422, everything else falls through to the existing 502. ## The real fix lives in Stripe config The defensive 422 here is just a clearer error surface. To actually unblock GBP/EUR users from changing tiers, the per-tier Stripe Prices (Pro, and Basic if priced) need `currency_options` for GBP added — Max already has this, which is why Max checkout shows the £/$ toggle. Stripe locks `currency_options` after a Price has been transacted, so the procedure is: create a new Price with USD + GBP from the start → update the `stripe-price-ids` LD flag to the new Price ID. No further code change required; same Price ID stays per tier, multiple currencies inside it. ## Checklist - [x] Component test for new banner copy - [x] Backend test for 422 currency-mismatch branch - [x] Format / lint / types pass - [x] No protected route added — N/A --- .../api/features/subscription_routes_test.py | 92 +++++++++++++++++++ .../backend/backend/api/features/v1.py | 29 ++++++ .../SubscriptionTierSection.test.tsx | 9 +- .../PendingChangeBanner.tsx | 2 +- .../__tests__/PendingChangeBanner.test.tsx | 4 +- 5 files changed, 126 insertions(+), 10 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 f9a4d954b1..681d53023f 100644 --- a/autogpt_platform/backend/backend/api/features/subscription_routes_test.py +++ b/autogpt_platform/backend/backend/api/features/subscription_routes_test.py @@ -416,6 +416,98 @@ def test_update_subscription_tier_paid_requires_urls( assert response.status_code == 422 +def test_update_subscription_tier_currency_mismatch_returns_422( + client: fastapi.testclient.TestClient, + mocker: pytest_mock.MockFixture, +) -> None: + """Stripe rejects a SubscriptionSchedule whose phases mix currencies (e.g. + GBP-checkout sub trying to schedule a USD-only target Price). The handler + must convert that into a specific 422 instead of the generic 502 so the + caller can tell the difference between a currency-config bug and a Stripe + outage.""" + mock_user = Mock() + mock_user.subscription_tier = SubscriptionTier.MAX + + async def mock_feature_enabled(*args, **kwargs): + return True + + mocker.patch( + "backend.api.features.v1.get_user_by_id", + new_callable=AsyncMock, + return_value=mock_user, + ) + mocker.patch( + "backend.api.features.v1.is_feature_enabled", + side_effect=mock_feature_enabled, + ) + mocker.patch( + "backend.api.features.v1.modify_stripe_subscription_for_tier", + side_effect=stripe.InvalidRequestError( + "The price specified only supports `usd`. This doesn't match the" + " expected currency: `gbp`.", + param="phases", + ), + ) + + response = client.post( + "/credits/subscription", + json={ + "tier": "PRO", + "success_url": f"{TEST_FRONTEND_ORIGIN}/success", + "cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel", + }, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert "billing currency" in detail.lower() + assert "contact support" in detail.lower() + + +def test_update_subscription_tier_non_currency_invalid_request_returns_502( + client: fastapi.testclient.TestClient, + mocker: pytest_mock.MockFixture, +) -> None: + """Locks the contract that *only* currency-mismatch InvalidRequestErrors + translate to 422 — every other Stripe InvalidRequestError must still + surface as the generic 502 so that widening the conditional later is + caught by the suite.""" + mock_user = Mock() + mock_user.subscription_tier = SubscriptionTier.MAX + + async def mock_feature_enabled(*args, **kwargs): + return True + + mocker.patch( + "backend.api.features.v1.get_user_by_id", + new_callable=AsyncMock, + return_value=mock_user, + ) + mocker.patch( + "backend.api.features.v1.is_feature_enabled", + side_effect=mock_feature_enabled, + ) + mocker.patch( + "backend.api.features.v1.modify_stripe_subscription_for_tier", + side_effect=stripe.InvalidRequestError( + "No such price: 'price_does_not_exist'", + param="items[0][price]", + ), + ) + + response = client.post( + "/credits/subscription", + json={ + "tier": "PRO", + "success_url": f"{TEST_FRONTEND_ORIGIN}/success", + "cancel_url": f"{TEST_FRONTEND_ORIGIN}/cancel", + }, + ) + + assert response.status_code == 502 + assert "billing currency" not in response.json()["detail"].lower() + + def test_update_subscription_tier_creates_checkout( client: fastapi.testclient.TestClient, mocker: pytest_mock.MockFixture, diff --git a/autogpt_platform/backend/backend/api/features/v1.py b/autogpt_platform/backend/backend/api/features/v1.py index 396b39f39f..1528097e65 100644 --- a/autogpt_platform/backend/backend/api/features/v1.py +++ b/autogpt_platform/backend/backend/api/features/v1.py @@ -1003,6 +1003,35 @@ async def update_subscription_tier( return await get_subscription_status(user_id) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) + except stripe.InvalidRequestError as e: + # Stripe rejects schedule modify when phases mix currencies, e.g. the + # active sub was checked out in GBP but the target tier's Price is + # USD-only. 502 reads as outage; surface a 422 with a specific message + # so the user/admin can see what to fix in Stripe. + msg = str(e) + if "currency" in msg.lower(): + logger.warning( + "Currency mismatch on tier change for user %s: %s", user_id, msg + ) + raise HTTPException( + status_code=422, + detail=( + "Tier change unavailable for your current billing currency." + " Please contact support — the target tier needs to be" + " configured for your currency in Stripe before this" + " change can go through." + ), + ) + logger.exception( + "Stripe error modifying subscription for user %s: %s", user_id, e + ) + raise HTTPException( + status_code=502, + detail=( + "Unable to update your subscription right now. " + "Please try again or contact support." + ), + ) except stripe.StripeError as e: logger.exception( "Stripe error modifying subscription for user %s: %s", user_id, e diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/__tests__/SubscriptionTierSection.test.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/__tests__/SubscriptionTierSection.test.tsx index fe17dabf47..f2653dbeba 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/__tests__/SubscriptionTierSection.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/__tests__/SubscriptionTierSection.test.tsx @@ -736,24 +736,19 @@ describe("SubscriptionTierSection", () => { ).toBeDefined(); }); - it("renders BASIC cancellation copy in banner when pending_tier is BASIC", () => { + it("renders cancellation copy in banner when pending_tier is NO_TIER", () => { setupMocks({ subscription: makeSubscription({ tier: "MAX", - pendingTier: "BASIC", - // Noon UTC so the local-formatted date lands on the same day - // regardless of the runner's timezone (midnight UTC drifts to - // the prior day in any timezone west of UTC). + pendingTier: "NO_TIER", pendingTierEffectiveAt: new Date("2026-05-15T12:00:00Z"), }), }); render(); - // Cancellation copy — distinct from the generic downgrade phrasing. expect( screen.getByText(/scheduled to cancel your subscription on/i), ).toBeDefined(); expect(screen.getByText(/May 15, 2026/)).toBeDefined(); - // Must NOT render the "downgrade to" phrasing on BASIC cancellation. expect(screen.queryByText(/scheduled to downgrade to/i)).toBeNull(); }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/components/PendingChangeBanner/PendingChangeBanner.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/components/PendingChangeBanner/PendingChangeBanner.tsx index b2b68ddbd8..709417896a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/components/PendingChangeBanner/PendingChangeBanner.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/components/PendingChangeBanner/PendingChangeBanner.tsx @@ -25,7 +25,7 @@ export function PendingChangeBanner({ const currentLabel = getTierLabel(currentTier); const dateText = formatPendingDate(pendingEffectiveAt); - const isCancellation = pendingTier === "BASIC"; + const isCancellation = pendingTier === "NO_TIER"; return (
{ const baseProps = { currentTier: "PRO", - pendingTier: "BASIC", + pendingTier: "NO_TIER", // Use noon UTC so the formatted local date lands on the same day // regardless of the host timezone (important for CI runners). pendingEffectiveAt: "2026-05-01T12:00:00Z", @@ -25,7 +25,7 @@ describe("PendingChangeBanner", () => { expect(container.firstChild).toBeNull(); }); - it("shows cancellation copy when pending tier is BASIC", () => { + it("shows cancellation copy when pending tier is NO_TIER", () => { render(); expect(screen.getByText(/cancel your subscription on/i)).toBeDefined(); expect(screen.getByText("May 1, 2026")).toBeDefined();