Skip to content

Commit bbf9f29

Browse files
fix(subscriptions): validate seat bounds on seat→seat product switches (#14198)
Co-authored-by: detail-app[bot] <180357370+detail-app[bot]@users.noreply.github.com>
1 parent a916e59 commit bbf9f29

4 files changed

Lines changed: 391 additions & 28 deletions

File tree

server/polar/subscription/service.py

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -276,40 +276,70 @@ def __init__(
276276

277277
class BelowMinimumSeats(PolarRequestValidationError):
278278
def __init__(
279-
self, subscription: Subscription, minimum_seats: int, requested_seats: int
279+
self,
280+
subscription: Subscription,
281+
minimum_seats: int,
282+
requested_seats: int,
283+
*,
284+
product_id: uuid.UUID | None = None,
280285
) -> None:
281286
self.subscription = subscription
282287
self.minimum_seats = minimum_seats
283288
self.requested_seats = requested_seats
284-
super().__init__(
285-
[
286-
{
287-
"type": "value_error",
288-
"loc": ("body", "seats"),
289-
"msg": f"Minimum {minimum_seats} seats required.",
290-
"input": requested_seats,
291-
}
292-
]
293-
)
289+
# A product change keeps the live seat count, so the offending input is
290+
# the target product, not a submitted seat count.
291+
error: ValidationError
292+
if product_id is not None:
293+
error = {
294+
"type": "value_error",
295+
"loc": ("body", "product_id"),
296+
"msg": (
297+
f"Current seat count of {requested_seats} is below the "
298+
f"minimum of {minimum_seats} seats for this product."
299+
),
300+
"input": product_id,
301+
}
302+
else:
303+
error = {
304+
"type": "value_error",
305+
"loc": ("body", "seats"),
306+
"msg": f"Minimum {minimum_seats} seats required.",
307+
"input": requested_seats,
308+
}
309+
super().__init__([error])
294310

295311

296312
class AboveMaximumSeats(PolarRequestValidationError):
297313
def __init__(
298-
self, subscription: Subscription, maximum_seats: int, requested_seats: int
314+
self,
315+
subscription: Subscription,
316+
maximum_seats: int,
317+
requested_seats: int,
318+
*,
319+
product_id: uuid.UUID | None = None,
299320
) -> None:
300321
self.subscription = subscription
301322
self.maximum_seats = maximum_seats
302323
self.requested_seats = requested_seats
303-
super().__init__(
304-
[
305-
{
306-
"type": "value_error",
307-
"loc": ("body", "seats"),
308-
"msg": f"Maximum {maximum_seats} seats allowed.",
309-
"input": requested_seats,
310-
}
311-
]
312-
)
324+
error: ValidationError
325+
if product_id is not None:
326+
error = {
327+
"type": "value_error",
328+
"loc": ("body", "product_id"),
329+
"msg": (
330+
f"Current seat count of {requested_seats} is above the "
331+
f"maximum of {maximum_seats} seats for this product."
332+
),
333+
"input": product_id,
334+
}
335+
else:
336+
error = {
337+
"type": "value_error",
338+
"loc": ("body", "seats"),
339+
"msg": f"Maximum {maximum_seats} seats allowed.",
340+
"input": requested_seats,
341+
}
342+
super().__init__([error])
313343

314344

315345
class NotAUnitBasedSubscription(PolarRequestValidationError):
@@ -1945,7 +1975,7 @@ async def update_product(
19451975
proration_behavior = organization.proration_behavior
19461976

19471977
is_initial_seat_transition = self._promote_seats_for_seat_transition(
1948-
subscription, currency_prices, proration_behavior
1978+
subscription, currency_prices, proration_behavior, product_id
19491979
)
19501980
self._promote_units_for_unit_transition(
19511981
subscription, currency_prices, proration_behavior, product_id
@@ -3165,7 +3195,7 @@ async def _compute_change_preview(
31653195
allowed_visibilities=allowed_visibilities,
31663196
)
31673197
self._promote_seats_for_seat_transition(
3168-
subscription, currency_prices, proration_behavior
3198+
subscription, currency_prices, proration_behavior, product_id
31693199
)
31703200
self._promote_units_for_unit_transition(
31713201
subscription, currency_prices, proration_behavior, product_id
@@ -3283,22 +3313,36 @@ def _promote_seats_for_seat_transition(
32833313
subscription: Subscription,
32843314
currency_prices: PriceSet,
32853315
proration_behavior: SubscriptionProrationBehavior,
3316+
product_id: uuid.UUID,
32863317
) -> bool:
32873318
"""Promote `subscription.seats` to the new product's first seat-price tier
32883319
minimum, so the proration debit and `apply_update`'s product-branch rebuild
32893320
both see a valid seat count. `next_period` is blocked because the post-apply
32903321
seat auto-claim has to run immediately, or the billing customer loses benefit
32913322
access. Returns whether this was a non-seat → seat transition.
32923323
"""
3293-
if any(is_seat_price(price) for price in subscription.prices):
3294-
return False
3295-
32963324
seat_price = next(
32973325
(price for price in currency_prices if is_seat_price(price)), None
32983326
)
32993327
if seat_price is None:
33003328
return False
33013329

3330+
if any(is_seat_price(price) for price in subscription.prices):
3331+
seats = subscription.seats
3332+
if seats is None:
3333+
return False
3334+
minimum_seats = seat_price.get_minimum_seats()
3335+
if seats < minimum_seats:
3336+
raise BelowMinimumSeats(
3337+
subscription, minimum_seats, seats, product_id=product_id
3338+
)
3339+
maximum_seats = seat_price.get_maximum_seats()
3340+
if maximum_seats is not None and seats > maximum_seats:
3341+
raise AboveMaximumSeats(
3342+
subscription, maximum_seats, seats, product_id=product_id
3343+
)
3344+
return False
3345+
33023346
if proration_behavior == SubscriptionProrationBehavior.next_period:
33033347
raise PolarRequestValidationError(
33043348
[

server/tests/customer_portal/endpoints/test_subscription.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from httpx import AsyncClient
55

66
from polar.enums import SubscriptionRecurringInterval
7+
from polar.kit.currency import PresentmentCurrency
78
from polar.kit.utils import utc_now
89
from polar.kit.visibility import Visibility
910
from polar.models import Customer, Member, Organization, Product, Subscription
1011
from polar.models.order import OrderStatus
12+
from polar.models.product_price import ProductPriceSeatUnit
1113
from polar.models.subscription import SubscriptionStatus
1214
from polar.postgres import AsyncSession
1315
from tests.fixtures.auth import (
@@ -24,6 +26,7 @@
2426
create_order,
2527
create_payment_method,
2628
create_product,
29+
create_subscription_with_seats,
2730
set_product_benefits,
2831
)
2932

@@ -241,6 +244,53 @@ async def test_update_plan_not_allowed(
241244
assert error["error"] == "UpdateSubscriptionPlanNotAllowed"
242245
assert "not allowed" in error["detail"].lower()
243246

247+
@pytest.mark.auth(CUSTOMER_AUTH_SUBJECT)
248+
async def test_seat_to_seat_below_target_minimum_returns_422_on_product_id(
249+
self,
250+
client: AsyncClient,
251+
save_fixture: SaveFixture,
252+
organization: Organization,
253+
customer: Customer,
254+
) -> None:
255+
old_seat_product = await create_product(
256+
save_fixture,
257+
organization=organization,
258+
recurring_interval=SubscriptionRecurringInterval.month,
259+
prices=[("seat", 1000, "usd")],
260+
)
261+
new_seat_product = await create_product(
262+
save_fixture,
263+
organization=organization,
264+
recurring_interval=SubscriptionRecurringInterval.month,
265+
prices=[],
266+
)
267+
new_seat_price = ProductPriceSeatUnit(
268+
price_currency=PresentmentCurrency.usd,
269+
seat_tiers={
270+
"tiers": [
271+
{"min_seats": 5, "max_seats": None, "price_per_seat": 2000},
272+
],
273+
},
274+
product=new_seat_product,
275+
)
276+
await save_fixture(new_seat_price)
277+
new_seat_product.prices.append(new_seat_price)
278+
await save_fixture(new_seat_product)
279+
280+
subscription = await create_subscription_with_seats(
281+
save_fixture, product=old_seat_product, customer=customer, seats=3
282+
)
283+
284+
response = await client.patch(
285+
f"/v1/customer-portal/subscriptions/{subscription.id}",
286+
json={"product_id": str(new_seat_product.id)},
287+
)
288+
289+
assert response.status_code == 422
290+
error = response.json()
291+
assert error["detail"][0]["loc"] == ["body", "product_id"]
292+
assert "below the minimum of 5 seats" in error["detail"][0]["msg"]
293+
244294

245295
@pytest.mark.asyncio
246296
class TestCustomerSubscriptionUpdateUnknownFields:

server/tests/subscription/test_endpoints.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from httpx import AsyncClient
77

88
from polar.auth.scope import Scope
9-
from polar.enums import SubscriptionRecurringInterval
9+
from polar.enums import SubscriptionProrationBehavior, SubscriptionRecurringInterval
10+
from polar.kit.currency import PresentmentCurrency
1011
from polar.kit.utils import utc_now
1112
from polar.kit.visibility import Visibility
1213
from polar.models import (
@@ -18,6 +19,7 @@
1819
)
1920
from polar.models.customer_seat import SeatStatus
2021
from polar.models.order import OrderStatus
22+
from polar.models.product_price import ProductPriceSeatUnit
2123
from polar.models.subscription import CustomerCancellationReason, SubscriptionStatus
2224
from polar.postgres import AsyncSession
2325
from tests.fixtures.auth import AuthSubjectFixture
@@ -548,6 +550,107 @@ async def test_valid(
548550
updated_subscription = response.json()
549551
assert updated_subscription["product"]["id"] == str(product_second.id)
550552

553+
@pytest.mark.auth
554+
async def test_seat_to_seat_below_target_minimum_returns_422_on_product_id(
555+
self,
556+
client: AsyncClient,
557+
save_fixture: SaveFixture,
558+
organization: Organization,
559+
user_organization: UserOrganization,
560+
customer: Customer,
561+
) -> None:
562+
old_seat_product = await create_product(
563+
save_fixture,
564+
organization=organization,
565+
recurring_interval=SubscriptionRecurringInterval.month,
566+
prices=[("seat", 1000, "usd")],
567+
)
568+
new_seat_product = await create_product(
569+
save_fixture,
570+
organization=organization,
571+
recurring_interval=SubscriptionRecurringInterval.month,
572+
prices=[],
573+
)
574+
new_seat_price = ProductPriceSeatUnit(
575+
price_currency=PresentmentCurrency.usd,
576+
seat_tiers={
577+
"tiers": [
578+
{"min_seats": 5, "max_seats": None, "price_per_seat": 2000},
579+
],
580+
},
581+
product=new_seat_product,
582+
)
583+
await save_fixture(new_seat_price)
584+
new_seat_product.prices.append(new_seat_price)
585+
await save_fixture(new_seat_product)
586+
587+
subscription = await create_subscription_with_seats(
588+
save_fixture, product=old_seat_product, customer=customer, seats=3
589+
)
590+
591+
response = await client.patch(
592+
f"/v1/subscriptions/{subscription.id}",
593+
json={"product_id": str(new_seat_product.id)},
594+
)
595+
596+
assert response.status_code == 422
597+
error = response.json()
598+
assert error["detail"][0]["loc"] == ["body", "product_id"]
599+
assert "below the minimum of 5 seats" in error["detail"][0]["msg"]
600+
assert error["detail"][0]["input"] == str(new_seat_product.id)
601+
602+
@pytest.mark.auth
603+
async def test_seat_to_seat_above_target_maximum_returns_422_on_product_id(
604+
self,
605+
client: AsyncClient,
606+
save_fixture: SaveFixture,
607+
organization: Organization,
608+
user_organization: UserOrganization,
609+
customer: Customer,
610+
) -> None:
611+
old_seat_product = await create_product(
612+
save_fixture,
613+
organization=organization,
614+
recurring_interval=SubscriptionRecurringInterval.month,
615+
prices=[("seat", 1000, "usd")],
616+
)
617+
new_seat_product = await create_product(
618+
save_fixture,
619+
organization=organization,
620+
recurring_interval=SubscriptionRecurringInterval.month,
621+
prices=[],
622+
)
623+
new_seat_price = ProductPriceSeatUnit(
624+
price_currency=PresentmentCurrency.usd,
625+
seat_tiers={
626+
"tiers": [
627+
{"min_seats": 1, "max_seats": 10, "price_per_seat": 1000},
628+
],
629+
},
630+
product=new_seat_product,
631+
)
632+
await save_fixture(new_seat_price)
633+
new_seat_product.prices.append(new_seat_price)
634+
await save_fixture(new_seat_product)
635+
636+
subscription = await create_subscription_with_seats(
637+
save_fixture, product=old_seat_product, customer=customer, seats=50
638+
)
639+
640+
response = await client.patch(
641+
f"/v1/subscriptions/{subscription.id}",
642+
json={
643+
"product_id": str(new_seat_product.id),
644+
"proration_behavior": SubscriptionProrationBehavior.next_period.value,
645+
},
646+
)
647+
648+
assert response.status_code == 422
649+
error = response.json()
650+
assert error["detail"][0]["loc"] == ["body", "product_id"]
651+
assert "above the maximum of 10 seats" in error["detail"][0]["msg"]
652+
assert error["detail"][0]["input"] == str(new_seat_product.id)
653+
551654

552655
@pytest.mark.asyncio
553656
class TestSubscriptionUpdateMetadata:

0 commit comments

Comments
 (0)