Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit c1cd5f9

Browse files
feat: 411 add available paid plans resolver (#181)
* feat: add available_plans resolver * Add functionality to available_plans rest route * bug: add variable for constant
1 parent 0741b80 commit c1cd5f9

8 files changed

Lines changed: 607 additions & 36 deletions

File tree

api/internal/tests/views/test_plans_viewset.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from rest_framework.test import APITestCase
66

77
from codecov_auth.tests.factories import OwnerFactory
8+
from plan.constants import TrialStatus
89
from utils.test_utils import Client
910

1011

@@ -243,3 +244,100 @@ def test_list_plans_anonymous_user(self):
243244
"trial_days": None,
244245
},
245246
]
247+
248+
def test_list_plans_team_user(self):
249+
current_owner = OwnerFactory(
250+
trial_status=TrialStatus.ONGOING.value, plan_user_count=4
251+
)
252+
client = Client()
253+
client.force_login_owner(current_owner)
254+
response = client.get(reverse("plans-list"))
255+
assert response.status_code == status.HTTP_200_OK
256+
assert response.data == [
257+
{
258+
"marketing_name": "Developer",
259+
"value": "users-free",
260+
"billing_rate": None,
261+
"base_unit_price": 0,
262+
"benefits": [
263+
"Up to 1 user",
264+
"Unlimited public repositories",
265+
"Unlimited private repositories",
266+
],
267+
"tier_name": "basic",
268+
"monthly_uploads_limit": None,
269+
"trial_days": None,
270+
},
271+
{
272+
"marketing_name": "Developer",
273+
"value": "users-basic",
274+
"billing_rate": None,
275+
"base_unit_price": 0,
276+
"benefits": [
277+
"Up to 1 user",
278+
"Unlimited public repositories",
279+
"Unlimited private repositories",
280+
],
281+
"tier_name": "basic",
282+
"monthly_uploads_limit": 250,
283+
"trial_days": None,
284+
},
285+
{
286+
"marketing_name": "Pro Team",
287+
"value": "users-pr-inappm",
288+
"billing_rate": "monthly",
289+
"base_unit_price": 12,
290+
"benefits": [
291+
"Configurable # of users",
292+
"Unlimited public repositories",
293+
"Unlimited private repositories",
294+
"Priority Support",
295+
],
296+
"tier_name": "pro",
297+
"monthly_uploads_limit": None,
298+
"trial_days": None,
299+
},
300+
{
301+
"marketing_name": "Pro Team",
302+
"value": "users-pr-inappy",
303+
"billing_rate": "annually",
304+
"base_unit_price": 10,
305+
"benefits": [
306+
"Configurable # of users",
307+
"Unlimited public repositories",
308+
"Unlimited private repositories",
309+
"Priority Support",
310+
],
311+
"tier_name": "pro",
312+
"monthly_uploads_limit": None,
313+
"trial_days": None,
314+
},
315+
{
316+
"marketing_name": "Team",
317+
"value": "users-teamm",
318+
"billing_rate": "monthly",
319+
"base_unit_price": 6,
320+
"benefits": [
321+
"Up to 10 users",
322+
"Unlimited public repositories",
323+
"Unlimited private repositories",
324+
],
325+
"tier_name": "team",
326+
"monthly_uploads_limit": 1000,
327+
"trial_days": None,
328+
},
329+
{
330+
"marketing_name": "Team",
331+
"value": "users-teamy",
332+
"billing_rate": "annually",
333+
"base_unit_price": 8,
334+
"benefits": [
335+
"Up to 10 users",
336+
"Unlimited public repositories",
337+
"Unlimited private repositories",
338+
],
339+
"tier_name": "team",
340+
"monthly_uploads_limit": 1000,
341+
"trial_days": None,
342+
},
343+
]

billing/helpers.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010
FREE_PLAN_REPRESENTATIONS,
1111
PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS,
1212
SENTRY_PAID_USER_PLAN_REPRESENTATIONS,
13+
TEAM_PLAN_MAX_USERS,
14+
TEAM_PLAN_REPRESENTATIONS,
1315
PlanData,
16+
TrialStatus,
1417
)
18+
from plan.service import PlanService
1519

1620

1721
def on_enterprise_plan(owner: Owner) -> bool:
18-
return settings.IS_ENTERPRISE or (
19-
owner.plan in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS.keys()
20-
)
22+
return settings.IS_ENTERPRISE or (owner.plan in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS.keys())
2123

2224

2325
def available_plans(owner: Optional[Owner]) -> List[dict]:
@@ -33,5 +35,15 @@ def available_plans(owner: Optional[Owner]) -> List[dict]:
3335
# these are only available to Sentry users
3436
plans += list(SENTRY_PAID_USER_PLAN_REPRESENTATIONS.values())
3537

38+
if owner:
39+
plan_service = PlanService(current_org=owner)
40+
41+
if (
42+
plan_service.trial_status == TrialStatus.ONGOING.value
43+
or plan_service.trial_status == TrialStatus.EXPIRED.value
44+
or plan_service.plan_name in TEAM_PLAN_REPRESENTATIONS
45+
) and plan_service.plan_user_count <= TEAM_PLAN_MAX_USERS:
46+
plans += TEAM_PLAN_REPRESENTATIONS.values()
47+
3648
plans = [asdict(plan) for plan in plans]
3749
return plans

graphql_api/tests/test_owner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,28 @@ def test_owner_pretrial_plan_benefits(self):
523523
"Unlimited private repositories",
524524
],
525525
}
526+
527+
@freeze_time("2023-06-19")
528+
def test_owner_available_plans(self):
529+
current_org = OwnerFactory(
530+
username="random-plan-user-123",
531+
service="github",
532+
plan=PlanName.CODECOV_PRO_MONTHLY.value,
533+
pretrial_users_count=123,
534+
)
535+
query = """{
536+
owner(username: "%s") {
537+
availablePlans {
538+
planName
539+
}
540+
}
541+
}
542+
""" % (
543+
current_org.username
544+
)
545+
data = self.gql_request(query, owner=current_org)
546+
assert data["owner"]["availablePlans"] == [
547+
{"planName": "users-basic"},
548+
{"planName": "users-pr-inappm"},
549+
{"planName": "users-pr-inappy"},
550+
]

graphql_api/types/owner/owner.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Owner {
2020
ownerid: Int!
2121
plan: Plan
2222
pretrialPlan: PlanRepresentation
23+
availablePlans: [PlanRepresentation!]!
2324
orgUploadToken: String
2425
defaultOrgUsername: String
2526
isCurrentUserActivated: Boolean!

graphql_api/types/owner/owner.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ def resolve_plan_representation(owner: Owner, info) -> PlanData:
7979
return FREE_PLAN_REPRESENTATIONS[PlanName.BASIC_PLAN_NAME.value]
8080

8181

82+
@owner_bindable.field("availablePlans")
83+
@convert_kwargs_to_snake_case
84+
def resolve_available_plans(owner: Owner, info) -> List[PlanData]:
85+
plan_service = PlanService(current_org=owner)
86+
return plan_service.available_plans
87+
88+
8289
@owner_bindable.field("repository")
8390
async def resolve_repository(owner, info, name):
8491
command = info.context["executor"].get_command("repository")

plan/constants.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -238,35 +238,39 @@ class PlanData:
238238
)
239239
}
240240

241+
BASIC_PLAN = PlanData(
242+
marketing_name=PlanMarketingName.BASIC.value,
243+
value=PlanName.BASIC_PLAN_NAME.value,
244+
billing_rate=None,
245+
base_unit_price=PlanPrice.CODECOV_BASIC.value,
246+
benefits=[
247+
"Up to 1 user",
248+
"Unlimited public repositories",
249+
"Unlimited private repositories",
250+
],
251+
tier_name=TierName.BASIC.value,
252+
monthly_uploads_limit=MonthlyUploadLimits.CODECOV_BASIC_PLAN.value,
253+
trial_days=None,
254+
)
255+
256+
FREE_PLAN = PlanData(
257+
marketing_name=PlanMarketingName.FREE.value,
258+
value=PlanName.FREE_PLAN_NAME.value,
259+
billing_rate=None,
260+
base_unit_price=PlanPrice.CODECOV_FREE.value,
261+
benefits=[
262+
"Up to 1 user",
263+
"Unlimited public repositories",
264+
"Unlimited private repositories",
265+
],
266+
tier_name=TierName.BASIC.value,
267+
trial_days=None,
268+
monthly_uploads_limit=None,
269+
)
270+
241271
FREE_PLAN_REPRESENTATIONS = {
242-
PlanName.FREE_PLAN_NAME.value: PlanData(
243-
marketing_name=PlanMarketingName.FREE.value,
244-
value=PlanName.FREE_PLAN_NAME.value,
245-
billing_rate=None,
246-
base_unit_price=PlanPrice.CODECOV_FREE.value,
247-
benefits=[
248-
"Up to 1 user",
249-
"Unlimited public repositories",
250-
"Unlimited private repositories",
251-
],
252-
tier_name=TierName.BASIC.value,
253-
trial_days=None,
254-
monthly_uploads_limit=None,
255-
),
256-
PlanName.BASIC_PLAN_NAME.value: PlanData(
257-
marketing_name=PlanMarketingName.BASIC.value,
258-
value=PlanName.BASIC_PLAN_NAME.value,
259-
billing_rate=None,
260-
base_unit_price=PlanPrice.CODECOV_BASIC.value,
261-
benefits=[
262-
"Up to 1 user",
263-
"Unlimited public repositories",
264-
"Unlimited private repositories",
265-
],
266-
tier_name=TierName.BASIC.value,
267-
monthly_uploads_limit=MonthlyUploadLimits.CODECOV_BASIC_PLAN.value,
268-
trial_days=None,
269-
),
272+
PlanName.FREE_PLAN_NAME.value: FREE_PLAN,
273+
PlanName.BASIC_PLAN_NAME.value: BASIC_PLAN,
270274
}
271275

272276
TEAM_PLAN_REPRESENTATIONS = {
@@ -349,3 +353,4 @@ class PlanData:
349353
]
350354

351355
TRIAL_PLAN_SEATS = 1000
356+
TEAM_PLAN_MAX_USERS = 10

plan/service.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
from codecov.commands.exceptions import ValidationError
66
from codecov_auth.models import Owner
77
from plan.constants import (
8+
BASIC_PLAN,
9+
FREE_PLAN,
810
FREE_PLAN_REPRESENTATIONS,
11+
PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS,
12+
SENTRY_PAID_USER_PLAN_REPRESENTATIONS,
13+
TEAM_PLAN_MAX_USERS,
14+
TEAM_PLAN_REPRESENTATIONS,
915
TRIAL_PLAN_SEATS,
1016
USER_PLAN_REPRESENTATIONS,
11-
MonthlyUploadLimits,
12-
PlanBillingRate,
13-
PlanMarketingName,
17+
PlanData,
1418
PlanName,
15-
PlanPrice,
16-
TierName,
1719
TrialDaysAmount,
1820
TrialStatus,
1921
)
22+
from services import sentry
2023

2124
log = logging.getLogger(__name__)
2225

@@ -98,6 +101,29 @@ def monthly_uploads_limit(self) -> Optional[int]:
98101
def tier_name(self) -> str:
99102
return self.plan_data.tier_name
100103

104+
@property
105+
def available_plans(self) -> List[PlanData]:
106+
available_plans = []
107+
available_plans.append(BASIC_PLAN)
108+
109+
if self.plan_name == FREE_PLAN.value:
110+
available_plans.append(FREE_PLAN)
111+
112+
if sentry.is_sentry_user(self.current_org):
113+
available_plans += SENTRY_PAID_USER_PLAN_REPRESENTATIONS.values()
114+
else:
115+
available_plans += PR_AUTHOR_PAID_USER_PLAN_REPRESENTATIONS.values()
116+
117+
# If you're trialing or have trialed and <= 10 users, or belong to the team plan
118+
if (
119+
self.trial_status == TrialStatus.ONGOING.value
120+
or self.trial_status == TrialStatus.EXPIRED.value
121+
or self.plan_name in TEAM_PLAN_REPRESENTATIONS
122+
) and self.plan_user_count <= TEAM_PLAN_MAX_USERS:
123+
available_plans += TEAM_PLAN_REPRESENTATIONS.values()
124+
125+
return available_plans
126+
101127
# Trial Data
102128
def start_trial(self, current_owner: Owner) -> None:
103129
"""

0 commit comments

Comments
 (0)