mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
Merge branch 'dev' into branch10
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<SubscriptionTierSection />);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PendingChangeBanner } from "../PendingChangeBanner";
|
||||
describe("PendingChangeBanner", () => {
|
||||
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(<PendingChangeBanner {...baseProps} />);
|
||||
expect(screen.getByText(/cancel your subscription on/i)).toBeDefined();
|
||||
expect(screen.getByText("May 1, 2026")).toBeDefined();
|
||||
|
||||
Reference in New Issue
Block a user