refactor(copilot): rename RateLimitTier to SubscriptionTier with Prisma enum

Rename `rateLimitTier` (String) to `subscriptionTier` (Prisma enum) across
the entire stack:

- schema.prisma: Add `SubscriptionTier` enum (FREE, STANDARD, PRO,
  ENTERPRISE), change User field from `rateLimitTier String` to
  `subscriptionTier SubscriptionTier`.
- migration.sql: CREATE TYPE + ALTER TABLE for the new enum column.
- rate_limit.py: Rename Python enum and update DB field references.
- All test files, admin routes, snapshots, and openapi.json updated to
  match the new naming.

Addresses PR feedback asking for a generic name and proper Prisma enum
instead of a free-form string.
This commit is contained in:
Zamil Majdy
2026-03-27 10:17:21 +07:00
parent e900ee615a
commit 85e9e4c5b7
14 changed files with 115 additions and 100 deletions

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel
from backend.copilot.config import ChatConfig
from backend.copilot.rate_limit import (
RateLimitTier,
SubscriptionTier,
get_global_rate_limits,
get_usage_status,
get_user_tier,
@@ -34,17 +34,17 @@ class UserRateLimitResponse(BaseModel):
weekly_token_limit: int
daily_tokens_used: int
weekly_tokens_used: int
tier: RateLimitTier
tier: SubscriptionTier
class UserTierResponse(BaseModel):
user_id: str
tier: RateLimitTier
tier: SubscriptionTier
class SetUserTierRequest(BaseModel):
user_id: str
tier: RateLimitTier
tier: SubscriptionTier
@router.get(

View File

@@ -8,7 +8,7 @@ import pytest_mock
from autogpt_libs.auth.jwt_utils import get_jwt_payload
from pytest_snapshot.plugin import Snapshot
from backend.copilot.rate_limit import CoPilotUsageStatus, RateLimitTier, UsageWindow
from backend.copilot.rate_limit import CoPilotUsageStatus, SubscriptionTier, UsageWindow
from .rate_limit_admin_routes import router as rate_limit_admin_router
@@ -53,7 +53,7 @@ def test_get_rate_limit(
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, RateLimitTier.STANDARD),
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
@@ -70,7 +70,7 @@ def test_get_rate_limit(
assert data["weekly_token_limit"] == 12_500_000
assert data["daily_tokens_used"] == 500_000
assert data["weekly_tokens_used"] == 3_000_000
assert data["tier"] == "standard"
assert data["tier"] == "FREE"
configured_snapshot.assert_match(
json.dumps(data, indent=2, sort_keys=True) + "\n",
@@ -91,7 +91,7 @@ def test_reset_user_usage_daily_only(
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, RateLimitTier.STANDARD),
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
@@ -109,7 +109,7 @@ def test_reset_user_usage_daily_only(
assert data["daily_tokens_used"] == 0
# Weekly is untouched
assert data["weekly_tokens_used"] == 3_000_000
assert data["tier"] == "standard"
assert data["tier"] == "FREE"
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=False)
@@ -132,7 +132,7 @@ def test_reset_user_usage_daily_and_weekly(
mocker.patch(
f"{_MOCK_MODULE}.get_global_rate_limits",
new_callable=AsyncMock,
return_value=(2_500_000, 12_500_000, RateLimitTier.STANDARD),
return_value=(2_500_000, 12_500_000, SubscriptionTier.FREE),
)
mocker.patch(
f"{_MOCK_MODULE}.get_usage_status",
@@ -149,7 +149,7 @@ def test_reset_user_usage_daily_and_weekly(
data = response.json()
assert data["daily_tokens_used"] == 0
assert data["weekly_tokens_used"] == 0
assert data["tier"] == "standard"
assert data["tier"] == "FREE"
mock_reset.assert_awaited_once_with(target_user_id, reset_weekly=True)
@@ -205,7 +205,7 @@ def test_get_user_tier(
mocker.patch(
f"{_MOCK_MODULE}.get_user_tier",
new_callable=AsyncMock,
return_value=RateLimitTier.PRO,
return_value=SubscriptionTier.PRO,
)
response = client.get("/admin/rate_limit/tier", params={"user_id": target_user_id})
@@ -213,7 +213,7 @@ def test_get_user_tier(
assert response.status_code == 200
data = response.json()
assert data["user_id"] == target_user_id
assert data["tier"] == "pro"
assert data["tier"] == "PRO"
def test_set_user_tier(
@@ -228,14 +228,14 @@ def test_set_user_tier(
response = client.post(
"/admin/rate_limit/tier",
json={"user_id": target_user_id, "tier": "max"},
json={"user_id": target_user_id, "tier": "ENTERPRISE"},
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == target_user_id
assert data["tier"] == "max"
mock_set.assert_awaited_once_with(target_user_id, RateLimitTier.MAX)
assert data["tier"] == "ENTERPRISE"
mock_set.assert_awaited_once_with(target_user_id, SubscriptionTier.ENTERPRISE)
def test_set_user_tier_invalid_tier(
@@ -265,7 +265,7 @@ def test_set_user_tier_user_not_found(
response = client.post(
"/admin/rate_limit/tier",
json={"user_id": target_user_id, "tier": "pro"},
json={"user_id": target_user_id, "tier": "PRO"},
)
assert response.status_code == 404
@@ -284,7 +284,7 @@ def test_set_user_tier_db_failure(
response = client.post(
"/admin/rate_limit/tier",
json={"user_id": target_user_id, "tier": "pro"},
json={"user_id": target_user_id, "tier": "PRO"},
)
assert response.status_code == 500
@@ -299,6 +299,6 @@ def test_tier_endpoints_require_admin_role(mock_jwt_user) -> None:
response = client.post(
"/admin/rate_limit/tier",
json={"user_id": "test", "tier": "pro"},
json={"user_id": "test", "tier": "PRO"},
)
assert response.status_code == 403

View File

@@ -9,7 +9,7 @@ import pytest
import pytest_mock
from backend.api.features.chat import routes as chat_routes
from backend.copilot.rate_limit import RateLimitTier
from backend.copilot.rate_limit import SubscriptionTier
app = fastapi.FastAPI()
app.include_router(chat_routes.router)
@@ -370,7 +370,7 @@ def test_usage_returns_daily_and_weekly(
daily_token_limit=10000,
weekly_token_limit=50000,
rate_limit_reset_cost=chat_routes.config.rate_limit_reset_cost,
tier=RateLimitTier.STANDARD,
tier=SubscriptionTier.FREE,
)
@@ -393,7 +393,7 @@ def test_usage_uses_config_limits(
daily_token_limit=99999,
weekly_token_limit=77777,
rate_limit_reset_cost=500,
tier=RateLimitTier.STANDARD,
tier=SubscriptionTier.FREE,
)

View File

@@ -189,7 +189,7 @@ async def test_create_store_submission(mocker):
notifyOnAgentApproved=True,
notifyOnAgentRejected=True,
timezone="Europe/Delft",
rateLimitTier="standard",
subscriptionTier=prisma.enums.SubscriptionTier.FREE,
)
mock_agent = prisma.models.AgentGraph(
id="agent-id",

View File

@@ -77,10 +77,11 @@ class ChatConfig(BaseSettings):
# allows ~70-100 turns/day.
# Checked at the HTTP layer (routes.py) before each turn.
#
# These are base limits for the "standard" tier. Higher tiers (pro, max)
# multiply these by their tier multiplier (see rate_limit.TIER_MULTIPLIERS).
# User tier is stored in the User.rateLimitTier DB column and resolved
# inside get_global_rate_limits().
# These are base limits for the FREE tier. Higher tiers (STANDARD, PRO,
# ENTERPRISE) multiply these by their tier multiplier (see
# rate_limit.TIER_MULTIPLIERS). User tier is stored in the
# User.subscriptionTier DB column and resolved inside
# get_global_rate_limits().
daily_token_limit: int = Field(
default=2_500_000,
description="Max tokens per day, resets at midnight UTC (0 = unlimited)",

View File

@@ -25,26 +25,28 @@ _USAGE_KEY_PREFIX = "copilot:usage"
# ---------------------------------------------------------------------------
# Rate-limit tier definitions
# Subscription tier definitions
# ---------------------------------------------------------------------------
class RateLimitTier(str, Enum):
"""Rate-limit tiers with increasing token allowances."""
class SubscriptionTier(str, Enum):
"""Subscription tiers with increasing token allowances."""
STANDARD = "standard"
PRO = "pro"
MAX = "max"
FREE = "FREE"
STANDARD = "STANDARD"
PRO = "PRO"
ENTERPRISE = "ENTERPRISE"
# Multiplier applied to the base limits (from LD / config) for each tier.
TIER_MULTIPLIERS: dict[RateLimitTier, int] = {
RateLimitTier.STANDARD: 1,
RateLimitTier.PRO: 5,
RateLimitTier.MAX: 25,
TIER_MULTIPLIERS: dict[SubscriptionTier, int] = {
SubscriptionTier.FREE: 1,
SubscriptionTier.STANDARD: 5,
SubscriptionTier.PRO: 10,
SubscriptionTier.ENTERPRISE: 25,
}
DEFAULT_TIER = RateLimitTier.STANDARD
DEFAULT_TIER = SubscriptionTier.FREE
class UsageWindow(BaseModel):
@@ -62,7 +64,7 @@ class CoPilotUsageStatus(BaseModel):
daily: UsageWindow
weekly: UsageWindow
tier: RateLimitTier = DEFAULT_TIER
tier: SubscriptionTier = DEFAULT_TIER
reset_cost: int = Field(
default=0,
description="Credit cost (in cents) to reset the daily limit. 0 = feature disabled.",
@@ -93,7 +95,7 @@ async def get_usage_status(
daily_token_limit: int,
weekly_token_limit: int,
rate_limit_reset_cost: int = 0,
tier: RateLimitTier = DEFAULT_TIER,
tier: SubscriptionTier = DEFAULT_TIER,
) -> CoPilotUsageStatus:
"""Get current usage status for a user.
@@ -373,19 +375,19 @@ async def record_token_usage(
@cached(maxsize=1000, ttl_seconds=300)
async def _fetch_user_tier(user_id: str) -> RateLimitTier:
async def _fetch_user_tier(user_id: str) -> SubscriptionTier:
"""Fetch the user's rate-limit tier from the database (cached).
Only successful DB lookups are cached. Raises on DB errors so the
``@cached`` decorator does **not** store a fallback value.
"""
user = await PrismaUser.prisma().find_unique(where={"id": user_id})
if user and user.rateLimitTier:
return RateLimitTier(user.rateLimitTier)
if user and user.subscriptionTier:
return SubscriptionTier(user.subscriptionTier)
return DEFAULT_TIER
async def get_user_tier(user_id: str) -> RateLimitTier:
async def get_user_tier(user_id: str) -> SubscriptionTier:
"""Look up the user's rate-limit tier from the database.
Successful results are cached for 5 minutes (via ``_fetch_user_tier``)
@@ -413,7 +415,7 @@ get_user_tier.cache_clear = _fetch_user_tier.cache_clear # type: ignore[attr-de
get_user_tier.cache_delete = _fetch_user_tier.cache_delete # type: ignore[attr-defined]
async def set_user_tier(user_id: str, tier: RateLimitTier) -> None:
async def set_user_tier(user_id: str, tier: SubscriptionTier) -> None:
"""Persist the user's rate-limit tier to the database.
Also invalidates the ``get_user_tier`` cache for this user so that
@@ -424,7 +426,7 @@ async def set_user_tier(user_id: str, tier: RateLimitTier) -> None:
"""
await PrismaUser.prisma().update(
where={"id": user_id},
data={"rateLimitTier": tier.value},
data={"subscriptionTier": tier.value},
)
# Invalidate cached tier so rate-limit checks pick up the change immediately.
get_user_tier.cache_delete(user_id) # type: ignore[attr-defined]
@@ -434,7 +436,7 @@ async def get_global_rate_limits(
user_id: str,
config_daily: int,
config_weekly: int,
) -> tuple[int, int, RateLimitTier]:
) -> tuple[int, int, SubscriptionTier]:
"""Resolve global rate limits from LaunchDarkly, falling back to config.
The base limits (from LD or config) are multiplied by the user's

View File

@@ -11,7 +11,7 @@ from .rate_limit import (
TIER_MULTIPLIERS,
CoPilotUsageStatus,
RateLimitExceeded,
RateLimitTier,
SubscriptionTier,
UsageWindow,
check_rate_limit,
get_global_rate_limits,
@@ -342,23 +342,25 @@ class TestRecordTokenUsage:
# ---------------------------------------------------------------------------
# RateLimitTier and tier multipliers
# SubscriptionTier and tier multipliers
# ---------------------------------------------------------------------------
class TestRateLimitTier:
class TestSubscriptionTier:
def test_tier_values(self):
assert RateLimitTier.STANDARD.value == "standard"
assert RateLimitTier.PRO.value == "pro"
assert RateLimitTier.MAX.value == "max"
assert SubscriptionTier.FREE.value == "FREE"
assert SubscriptionTier.STANDARD.value == "STANDARD"
assert SubscriptionTier.PRO.value == "PRO"
assert SubscriptionTier.ENTERPRISE.value == "ENTERPRISE"
def test_tier_multipliers(self):
assert TIER_MULTIPLIERS[RateLimitTier.STANDARD] == 1
assert TIER_MULTIPLIERS[RateLimitTier.PRO] == 5
assert TIER_MULTIPLIERS[RateLimitTier.MAX] == 25
assert TIER_MULTIPLIERS[SubscriptionTier.FREE] == 1
assert TIER_MULTIPLIERS[SubscriptionTier.STANDARD] == 5
assert TIER_MULTIPLIERS[SubscriptionTier.PRO] == 10
assert TIER_MULTIPLIERS[SubscriptionTier.ENTERPRISE] == 25
def test_default_tier_is_standard(self):
assert DEFAULT_TIER == RateLimitTier.STANDARD
def test_default_tier_is_free(self):
assert DEFAULT_TIER == SubscriptionTier.FREE
def test_usage_status_includes_tier(self):
now = datetime.now(UTC)
@@ -366,17 +368,17 @@ class TestRateLimitTier:
daily=UsageWindow(used=0, limit=100, resets_at=now + timedelta(hours=1)),
weekly=UsageWindow(used=0, limit=500, resets_at=now + timedelta(days=1)),
)
# Default tier should be STANDARD
assert status.tier == RateLimitTier.STANDARD
# Default tier should be FREE
assert status.tier == SubscriptionTier.FREE
def test_usage_status_with_custom_tier(self):
now = datetime.now(UTC)
status = CoPilotUsageStatus(
daily=UsageWindow(used=0, limit=100, resets_at=now + timedelta(hours=1)),
weekly=UsageWindow(used=0, limit=500, resets_at=now + timedelta(days=1)),
tier=RateLimitTier.PRO,
tier=SubscriptionTier.PRO,
)
assert status.tier == RateLimitTier.PRO
assert status.tier == SubscriptionTier.PRO
# ---------------------------------------------------------------------------
@@ -394,7 +396,7 @@ class TestGetUserTier:
async def test_returns_tier_from_db(self):
"""Should return the tier stored in the user record."""
mock_user = MagicMock()
mock_user.rateLimitTier = "pro"
mock_user.subscriptionTier = "PRO"
mock_prisma = AsyncMock()
mock_prisma.find_unique = AsyncMock(return_value=mock_user)
@@ -405,7 +407,7 @@ class TestGetUserTier:
):
tier = await get_user_tier(_USER)
assert tier == RateLimitTier.PRO
assert tier == SubscriptionTier.PRO
@pytest.mark.asyncio
async def test_returns_default_when_user_not_found(self):
@@ -423,9 +425,9 @@ class TestGetUserTier:
@pytest.mark.asyncio
async def test_returns_default_when_tier_is_none(self):
"""Should return DEFAULT_TIER when rateLimitTier is None."""
"""Should return DEFAULT_TIER when subscriptionTier is None."""
mock_user = MagicMock()
mock_user.rateLimitTier = None
mock_user.subscriptionTier = None
mock_prisma = AsyncMock()
mock_prisma.find_unique = AsyncMock(return_value=mock_user)
@@ -471,7 +473,7 @@ class TestGetUserTier:
# Now DB recovers and returns PRO
mock_user = MagicMock()
mock_user.rateLimitTier = "pro"
mock_user.subscriptionTier = "PRO"
ok_prisma = AsyncMock()
ok_prisma.find_unique = AsyncMock(return_value=mock_user)
@@ -482,13 +484,13 @@ class TestGetUserTier:
tier2 = await get_user_tier(_USER)
# Should get PRO now — the error result was not cached
assert tier2 == RateLimitTier.PRO
assert tier2 == SubscriptionTier.PRO
@pytest.mark.asyncio
async def test_returns_default_on_invalid_tier_value(self):
"""Should fall back to DEFAULT_TIER when stored value is invalid."""
mock_user = MagicMock()
mock_user.rateLimitTier = "invalid-tier"
mock_user.subscriptionTier = "invalid-tier"
mock_prisma = AsyncMock()
mock_prisma.find_unique = AsyncMock(return_value=mock_user)
@@ -523,13 +525,13 @@ class TestGetGlobalRateLimitsWithTiers:
return _side_effect
@pytest.mark.asyncio
async def test_standard_tier_no_multiplier(self):
"""Standard tier should not change limits."""
async def test_free_tier_no_multiplier(self):
"""Free tier should not change limits."""
with (
patch(
"backend.copilot.rate_limit.get_user_tier",
new_callable=AsyncMock,
return_value=RateLimitTier.STANDARD,
return_value=SubscriptionTier.FREE,
),
patch(
"backend.util.feature_flag.get_feature_flag_value",
@@ -542,16 +544,16 @@ class TestGetGlobalRateLimitsWithTiers:
assert daily == 2_500_000
assert weekly == 12_500_000
assert tier == RateLimitTier.STANDARD
assert tier == SubscriptionTier.FREE
@pytest.mark.asyncio
async def test_pro_tier_5x_multiplier(self):
"""Pro tier should multiply limits by 5."""
async def test_standard_tier_5x_multiplier(self):
"""Standard tier should multiply limits by 5."""
with (
patch(
"backend.copilot.rate_limit.get_user_tier",
new_callable=AsyncMock,
return_value=RateLimitTier.PRO,
return_value=SubscriptionTier.STANDARD,
),
patch(
"backend.util.feature_flag.get_feature_flag_value",
@@ -564,16 +566,16 @@ class TestGetGlobalRateLimitsWithTiers:
assert daily == 12_500_000
assert weekly == 62_500_000
assert tier == RateLimitTier.PRO
assert tier == SubscriptionTier.STANDARD
@pytest.mark.asyncio
async def test_max_tier_25x_multiplier(self):
"""Max tier should multiply limits by 25."""
async def test_enterprise_tier_25x_multiplier(self):
"""Enterprise tier should multiply limits by 25."""
with (
patch(
"backend.copilot.rate_limit.get_user_tier",
new_callable=AsyncMock,
return_value=RateLimitTier.MAX,
return_value=SubscriptionTier.ENTERPRISE,
),
patch(
"backend.util.feature_flag.get_feature_flag_value",
@@ -586,7 +588,7 @@ class TestGetGlobalRateLimitsWithTiers:
assert daily == 62_500_000
assert weekly == 312_500_000
assert tier == RateLimitTier.MAX
assert tier == SubscriptionTier.ENTERPRISE
# ---------------------------------------------------------------------------

View File

@@ -1841,7 +1841,7 @@ async def stream_chat_completion_sdk(
"conversation_turn": str(turn),
}
if _user_tier:
_otel_metadata["rate_limit_tier"] = _user_tier.value
_otel_metadata["subscription_tier"] = _user_tier.value
_otel_ctx = propagate_attributes(
user_id=user_id,

View File

@@ -1,2 +1,5 @@
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('FREE', 'STANDARD', 'PRO', 'ENTERPRISE');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "rateLimitTier" TEXT NOT NULL DEFAULT 'standard';
ALTER TABLE "User" ADD COLUMN "subscriptionTier" "SubscriptionTier" NOT NULL DEFAULT 'FREE';

View File

@@ -40,9 +40,9 @@ model User {
timezone String @default("not-set")
// CoPilot rate-limit tier: "standard" (default), "pro", or "max".
// Multipliers applied in get_global_rate_limits(): standard=1x, pro=5x, max=25x.
rateLimitTier String @default("standard")
// CoPilot subscription tier — controls rate-limit multipliers.
// Multipliers applied in get_global_rate_limits(): FREE=1x, STANDARD=5x, PRO=10x, ENTERPRISE=25x.
subscriptionTier SubscriptionTier @default(FREE)
// Relations
@@ -77,6 +77,13 @@ model User {
OAuthRefreshTokens OAuthRefreshToken[]
}
enum SubscriptionTier {
FREE
STANDARD
PRO
ENTERPRISE
}
enum OnboardingStep {
// Introductory onboarding (Library)
WELCOME

View File

@@ -1,7 +1,7 @@
{
"daily_token_limit": 2500000,
"daily_tokens_used": 500000,
"tier": "standard",
"tier": "FREE",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 3000000

View File

@@ -1,7 +1,7 @@
{
"daily_token_limit": 2500000,
"daily_tokens_used": 0,
"tier": "standard",
"tier": "FREE",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 0

View File

@@ -1,7 +1,7 @@
{
"daily_token_limit": 2500000,
"daily_tokens_used": 0,
"tier": "standard",
"tier": "FREE",
"user_id": "5e53486c-cf57-477e-ba2a-cb02dc828e1c",
"weekly_token_limit": 12500000,
"weekly_tokens_used": 3000000

View File

@@ -8478,8 +8478,8 @@
"daily": { "$ref": "#/components/schemas/UsageWindow" },
"weekly": { "$ref": "#/components/schemas/UsageWindow" },
"tier": {
"$ref": "#/components/schemas/RateLimitTier",
"default": "standard"
"$ref": "#/components/schemas/SubscriptionTier",
"default": "FREE"
},
"reset_cost": {
"type": "integer",
@@ -11823,12 +11823,6 @@
"title": "RateLimitResetResponse",
"description": "Response from resetting the daily rate limit."
},
"RateLimitTier": {
"type": "string",
"enum": ["standard", "pro", "max"],
"title": "RateLimitTier",
"description": "Rate-limit tiers with increasing token allowances."
},
"RecentExecution": {
"properties": {
"status": { "type": "string", "title": "Status" },
@@ -12218,7 +12212,7 @@
"SetUserTierRequest": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
"tier": { "$ref": "#/components/schemas/RateLimitTier" }
"tier": { "$ref": "#/components/schemas/SubscriptionTier" }
},
"type": "object",
"required": ["user_id", "tier"],
@@ -12993,6 +12987,12 @@
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
"title": "SubmissionStatus"
},
"SubscriptionTier": {
"type": "string",
"enum": ["FREE", "STANDARD", "PRO", "ENTERPRISE"],
"title": "SubscriptionTier",
"description": "Subscription tiers with increasing token allowances."
},
"SuggestedGoalResponse": {
"properties": {
"type": {
@@ -14808,7 +14808,7 @@
"type": "integer",
"title": "Weekly Tokens Used"
},
"tier": { "$ref": "#/components/schemas/RateLimitTier" }
"tier": { "$ref": "#/components/schemas/SubscriptionTier" }
},
"type": "object",
"required": [
@@ -14847,7 +14847,7 @@
"UserTierResponse": {
"properties": {
"user_id": { "type": "string", "title": "User Id" },
"tier": { "$ref": "#/components/schemas/RateLimitTier" }
"tier": { "$ref": "#/components/schemas/SubscriptionTier" }
},
"type": "object",
"required": ["user_id", "tier"],