fix(backend): guard modify_stripe_subscription_for_tier against orphaned customers

Add early return when user has no stripe_customer_id to prevent creating
an orphaned Stripe customer if a subsequent Subscription.list call fails.
Follows the same pattern as cancel_stripe_subscription and
get_proration_credit_cents. Update tests to mock get_user_by_id and add
a test for the no-customer-id path.
This commit is contained in:
majdyz
2026-04-15 19:53:22 +07:00
parent 10b92fbaa2
commit 2f75eff082
2 changed files with 53 additions and 9 deletions

View File

@@ -1437,7 +1437,15 @@ async def modify_stripe_subscription_for_tier(
if not price_id:
raise ValueError(f"No Stripe price ID configured for tier {tier}")
customer_id = await get_stripe_customer_id(user_id)
# Guard: only proceed if the user already has a Stripe customer ID. Calling
# get_stripe_customer_id for a user with no Stripe record (e.g. admin-granted tier)
# would create an orphaned customer object if the subsequent Subscription.list call
# fails. Return False early so the API layer falls back to Checkout instead.
user = await get_user_by_id(user_id)
if not user.stripe_customer_id:
return False
customer_id = user.stripe_customer_id
for status in ("active", "trialing"):
subscriptions = await run_in_threadpool(
stripe.Subscription.list, customer=customer_id, status=status, limit=1

View File

@@ -1106,6 +1106,9 @@ async def test_modify_stripe_subscription_for_tier_modifies_existing_sub():
mock_list = MagicMock()
mock_list.data = [mock_sub]
mock_user = MagicMock(spec=User)
mock_user.stripe_customer_id = "cus_abc"
with (
patch(
"backend.data.credit.get_subscription_price_id",
@@ -1113,9 +1116,9 @@ async def test_modify_stripe_subscription_for_tier_modifies_existing_sub():
return_value="price_pro_monthly",
),
patch(
"backend.data.credit.get_stripe_customer_id",
"backend.data.credit.get_user_by_id",
new_callable=AsyncMock,
return_value="cus_abc",
return_value=mock_user,
),
patch(
"backend.data.credit.stripe.Subscription.list",
@@ -1138,10 +1141,15 @@ async def test_modify_stripe_subscription_for_tier_modifies_existing_sub():
@pytest.mark.asyncio
async def test_modify_stripe_subscription_for_tier_returns_false_when_no_sub():
"""modify_stripe_subscription_for_tier returns False when no active subscription exists."""
mock_list = MagicMock()
mock_list.data = []
async def test_modify_stripe_subscription_for_tier_returns_false_when_no_customer_id():
"""modify_stripe_subscription_for_tier returns False when user has no Stripe customer ID.
Admin-granted paid tiers have no Stripe customer record. Calling
get_stripe_customer_id would create an orphaned customer if a subsequent API call
fails, so the function returns False early and the API layer falls back to Checkout.
"""
mock_user = MagicMock(spec=User)
mock_user.stripe_customer_id = None
with (
patch(
@@ -1150,9 +1158,37 @@ async def test_modify_stripe_subscription_for_tier_returns_false_when_no_sub():
return_value="price_pro_monthly",
),
patch(
"backend.data.credit.get_stripe_customer_id",
"backend.data.credit.get_user_by_id",
new_callable=AsyncMock,
return_value="cus_abc",
return_value=mock_user,
),
):
result = await modify_stripe_subscription_for_tier(
"user-1", SubscriptionTier.PRO
)
assert result is False
@pytest.mark.asyncio
async def test_modify_stripe_subscription_for_tier_returns_false_when_no_sub():
"""modify_stripe_subscription_for_tier returns False when no active subscription exists."""
mock_list = MagicMock()
mock_list.data = []
mock_user = MagicMock(spec=User)
mock_user.stripe_customer_id = "cus_abc"
with (
patch(
"backend.data.credit.get_subscription_price_id",
new_callable=AsyncMock,
return_value="price_pro_monthly",
),
patch(
"backend.data.credit.get_user_by_id",
new_callable=AsyncMock,
return_value=mock_user,
),
patch(
"backend.data.credit.stripe.Subscription.list",