diff --git a/api/internal/owner/serializers.py b/api/internal/owner/serializers.py index 7672f61d64..26584562f2 100644 --- a/api/internal/owner/serializers.py +++ b/api/internal/owner/serializers.py @@ -7,14 +7,12 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from shared.plan.constants import ( - PAID_PLANS, - SENTRY_PAID_USER_PLAN_REPRESENTATIONS, TEAM_PLAN_MAX_USERS, - TEAM_PLAN_REPRESENTATIONS, + TierName, ) from shared.plan.service import PlanService -from codecov_auth.models import Owner +from codecov_auth.models import Owner, Plan from services.billing import BillingService from services.sentry import send_user_webhook as send_sentry_webhook @@ -137,11 +135,6 @@ def validate_value(self, value: str) -> str: plan["value"] for plan in plan_service.available_plans(current_owner) ] if value not in plan_values: - if value in SENTRY_PAID_USER_PLAN_REPRESENTATIONS: - log.warning( - "Non-Sentry user attempted to transition to Sentry plan", - extra=dict(owner_id=current_owner.pk, plan=value), - ) raise serializers.ValidationError( f"Invalid value for plan: {value}; must be one of {plan_values}" ) @@ -154,8 +147,17 @@ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]: detail="You cannot update your plan manually, for help or changes to plan, connect with sales@codecov.io" ) + active_plans = Plan.objects.select_related("tier").filter( + paid_plan=True, is_active=True + ) + + active_plan_names = set(active_plans.values_list("name", flat=True)) + team_tier_plans = active_plans.filter( + tier__tier_name=TierName.TEAM.value + ).values_list("name", flat=True) + # Validate quantity here because we need access to whole plan object - if plan["value"] in PAID_PLANS: + if plan["value"] in active_plan_names: if "quantity" not in plan: raise serializers.ValidationError( "Field 'quantity' required for updating to paid plans" @@ -184,7 +186,7 @@ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]: "Quantity or plan for paid plan must be different from the existing one" ) if ( - plan["value"] in TEAM_PLAN_REPRESENTATIONS + plan["value"] in team_tier_plans and plan["quantity"] > TEAM_PLAN_MAX_USERS ): raise serializers.ValidationError( @@ -219,7 +221,7 @@ def get_plan(self, phase: Dict[str, Any]) -> str: plan_name = list(stripe_plan_dict.keys())[ list(stripe_plan_dict.values()).index(plan_id) ] - marketing_plan_name = PAID_PLANS[plan_name].billing_rate + marketing_plan_name = Plan.objects.get(name=plan_name).marketing_name return marketing_plan_name def get_quantity(self, phase: Dict[str, Any]) -> int: @@ -342,7 +344,11 @@ def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object: instance, desired_plan ) - if desired_plan["value"] in SENTRY_PAID_USER_PLAN_REPRESENTATIONS: + sentry_plans = Plan.objects.filter( + tier__tier_name=TierName.SENTRY.value, is_active=True + ).values_list("name", flat=True) + + if desired_plan["value"] in sentry_plans: current_owner = self.context["view"].request.current_owner send_sentry_webhook(current_owner, instance) diff --git a/api/internal/tests/test_pagination.py b/api/internal/tests/test_pagination.py index 3bde43a69e..ca972f18c2 100644 --- a/api/internal/tests/test_pagination.py +++ b/api/internal/tests/test_pagination.py @@ -1,6 +1,8 @@ from rest_framework.reverse import reverse from rest_framework.test import APITestCase +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import OwnerFactory +from shared.plan.constants import TierName from utils.test_utils import Client @@ -8,7 +10,9 @@ class PageNumberPaginationTests(APITestCase): def setUp(self): self.client = Client() - self.owner = OwnerFactory(plan="users-free", plan_user_count=5) + tier = TierFactory(tier_name=TierName.BASIC.value) + plan = PlanFactory(tier=tier, is_active=True) + self.owner = OwnerFactory(plan=plan.name, plan_user_count=5) self.users = [ OwnerFactory(organizations=[self.owner.ownerid]), OwnerFactory(organizations=[self.owner.ownerid]), diff --git a/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml b/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml new file mode 100644 index 0000000000..2ee7c35712 --- /dev/null +++ b/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_must_fail_if_team_plan_and_too_many_users.yaml @@ -0,0 +1,92 @@ +interactions: +- request: + body: billing_address_collection=required&payment_method_collection=if_required&client_reference_id=65&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjustin47%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjustin47%3Fcancel&customer=1000&mode=subscription&line_items[0][price]=price_1OCM0gGlVGuVgOrkWDYEBtSL&line_items[0][quantity]=11&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=65&subscription_data[metadata][username]=justin47&subscription_data[metadata][obo_name]=Kelly+Williams&subscription_data[metadata][obo_email]=christopher27%40lopez-welch.com&subscription_data[metadata][obo]=65&tax_id_collection[enabled]=True&customer_update[name]=auto&customer_update[address]=auto + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '744' + Content-Type: + - application/x-www-form-urlencoded + Idempotency-Key: + - 2e7e12e1-9051-4766-b947-1abe985b5e98 + Stripe-Version: + - 2024-12-18.acacia + User-Agent: + - Stripe/v1 PythonBindings/11.4.1 + X-Stripe-Client-User-Agent: + - '{"bindings_version": "11.4.1", "lang": "python", "publisher": "stripe", "httplib": + "requests", "lang_version": "3.12.8", "platform": "Linux-6.10.14-linuxkit-aarch64-with-glibc2.36", + "uname": "Linux 35c9e7c77efc 6.10.14-linuxkit #1 SMP Fri Nov 29 17:22:03 UTC + 2024 aarch64 "}' + method: POST + uri: https://api.stripe.com/v1/checkout/sessions + response: + body: + string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\": + \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\": + \"No such customer: '1000'\",\n \"param\": \"customer\",\n \"request_log_url\": + \"https://dashboard.stripe.com/test/logs/req_oevRZUbMiaT1kM?t=1737668618\",\n + \ \"type\": \"invalid_request_error\"\n }\n}\n" + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '325' + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; upgrade-insecure-requests; + report-uri https://q.stripe.com/csp-violation?q=ieXMYrsoTw4hsNfwL6RhDPN6zQnVwnAAc0QsFb8i8xtl4N7V94VZzDmgDhKzWxW8mHRRg08-d5GW6oHr + Content-Type: + - application/json + Cross-Origin-Opener-Policy-Report-Only: + - same-origin; report-to="coop" + Date: + - Thu, 23 Jan 2025 21:43:38 GMT + Idempotency-Key: + - 2e7e12e1-9051-4766-b947-1abe985b5e98 + Original-Request: + - req_oevRZUbMiaT1kM + Report-To: + - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}' + Reporting-Endpoints: + - coop="https://q.stripe.com/coop-report" + Request-Id: + - req_oevRZUbMiaT1kM + Server: + - nginx + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Stripe-Version: + - 2024-12-18.acacia + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - AB + status: + code: 400 + message: Bad Request +version: 1 diff --git a/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml b/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml new file mode 100644 index 0000000000..f8c6ccc4d2 --- /dev/null +++ b/api/internal/tests/views/cassetes/test_account_viewset/AccountViewSetTests/test_update_team_plan_must_fail_if_currently_team_plan_add_too_many_users.yaml @@ -0,0 +1,95 @@ +interactions: +- request: + body: billing_address_collection=required&payment_method_collection=if_required&client_reference_id=93&success_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjocelyn62%3Fsuccess&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fplan%2Fgh%2Fjocelyn62%3Fcancel&customer=1000&mode=subscription&line_items[0][price]=price_1OCM0gGlVGuVgOrkWDYEBtSL&line_items[0][quantity]=11&subscription_data[metadata][service]=github&subscription_data[metadata][obo_organization]=93&subscription_data[metadata][username]=jocelyn62&subscription_data[metadata][obo_name]=Crystal+Schmitt&subscription_data[metadata][obo_email]=smithamanda%40flowers.biz&subscription_data[metadata][obo]=93&tax_id_collection[enabled]=True&customer_update[name]=auto&customer_update[address]=auto + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '742' + Content-Type: + - application/x-www-form-urlencoded + Idempotency-Key: + - 26000f5a-feb8-4647-ac57-32a5e5ff8729 + Stripe-Version: + - 2024-12-18.acacia + User-Agent: + - Stripe/v1 PythonBindings/11.4.1 + X-Stripe-Client-Telemetry: + - '{"last_request_metrics": {"request_id": "req_AaY8IvHbbSDcvz", "request_duration_ms": + 2}}' + X-Stripe-Client-User-Agent: + - '{"bindings_version": "11.4.1", "lang": "python", "publisher": "stripe", "httplib": + "requests", "lang_version": "3.12.8", "platform": "Linux-6.10.14-linuxkit-aarch64-with-glibc2.36", + "uname": "Linux 35c9e7c77efc 6.10.14-linuxkit #1 SMP Fri Nov 29 17:22:03 UTC + 2024 aarch64 "}' + method: POST + uri: https://api.stripe.com/v1/checkout/sessions + response: + body: + string: "{\n \"error\": {\n \"code\": \"resource_missing\",\n \"doc_url\": + \"https://stripe.com/docs/error-codes/resource-missing\",\n \"message\": + \"No such customer: '1000'\",\n \"param\": \"customer\",\n \"request_log_url\": + \"https://dashboard.stripe.com/test/logs/req_k8lY68XdxWIFHo?t=1737668619\",\n + \ \"type\": \"invalid_request_error\"\n }\n}\n" + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, HEAD, PUT, PATCH, POST, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, + X-Stripe-Privileged-Session-Required + Access-Control-Max-Age: + - '300' + Cache-Control: + - no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '325' + Content-Security-Policy: + - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none'; + img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; upgrade-insecure-requests; + report-uri https://q.stripe.com/csp-violation?q=1vgSiZ6UPd0qoBXo1Mjsk02GXFGP3M7PXsjua2jiowWQKm8jByxTHbhTeRirsQsrZ7jscQLjXtdCc_sh + Content-Type: + - application/json + Cross-Origin-Opener-Policy-Report-Only: + - same-origin; report-to="coop" + Date: + - Thu, 23 Jan 2025 21:43:39 GMT + Idempotency-Key: + - 26000f5a-feb8-4647-ac57-32a5e5ff8729 + Original-Request: + - req_k8lY68XdxWIFHo + Report-To: + - '{"group":"coop","max_age":8640,"endpoints":[{"url":"https://q.stripe.com/coop-report"}],"include_subdomains":true}' + Reporting-Endpoints: + - coop="https://q.stripe.com/coop-report" + Request-Id: + - req_k8lY68XdxWIFHo + Server: + - nginx + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Stripe-Version: + - 2024-12-18.acacia + Vary: + - Origin + X-Content-Type-Options: + - nosniff + X-Stripe-Priority-Routing-Enabled: + - 'true' + X-Stripe-Routing-Context-Priority-Tier: + - api-testmode + X-Wc: + - AB + status: + code: 400 + message: Bad Request +version: 1 diff --git a/api/internal/tests/views/test_account_viewset.py b/api/internal/tests/views/test_account_viewset.py index c183cfef24..f9d608e7a7 100644 --- a/api/internal/tests/views/test_account_viewset.py +++ b/api/internal/tests/views/test_account_viewset.py @@ -18,6 +18,7 @@ from stripe import StripeError from api.internal.tests.test_utils import GetAdminProviderAdapter +from billing.helpers import mock_all_plans_and_tiers from codecov_auth.models import Service from utils.test_utils import APIClient @@ -91,6 +92,11 @@ def _update(self, kwargs, data): def _destroy(self, kwargs): return self.client.delete(reverse("account_details-detail", kwargs=kwargs)) + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + def setUp(self): self.service = "gitlab" self.current_owner = OwnerFactory( @@ -120,7 +126,7 @@ def setUp(self): "description": "(10) users-pr-inappm", "amount": 120, "currency": "usd", - "plan_name": "users-pr-inappm", + "plan_name": PlanName.CODECOV_PRO_MONTHLY.value, "quantity": 1, "period": {"end": 1521326190, "start": 1518906990}, } @@ -230,11 +236,17 @@ def test_retrieve_account_gets_account_fields_when_there_are_scheduled_details( ) assert response.status_code == status.HTTP_200_OK assert response.data == { - "activated_user_count": 0, - "root_organization": None, "integration_id": owner.integration_id, - "plan_auto_activate": owner.plan_auto_activate, + "activated_student_count": 0, + "activated_user_count": 0, + "checkout_session_id": None, + "delinquent": None, + "email": owner.email, "inactive_user_count": 1, + "name": owner.name, + "nb_active_private_repos": 0, + "plan_auto_activate": True, + "plan_provider": owner.plan_provider, "plan": { "marketing_name": "Developer", "value": PlanName.BASIC_PLAN_NAME.value, @@ -247,6 +259,17 @@ def test_retrieve_account_gets_account_fields_when_there_are_scheduled_details( ], "quantity": 1, }, + "repo_total_credits": 99999999, + "root_organization": None, + "schedule_detail": { + "id": "123", + "scheduled_phase": { + "start_date": schedule_params["start_date"], + "plan": "Pro", + "quantity": schedule_params["quantity"], + }, + }, + "student_count": 0, "subscription_detail": { "latest_invoice": None, "default_payment_method": None, @@ -254,27 +277,10 @@ def test_retrieve_account_gets_account_fields_when_there_are_scheduled_details( "current_period_end": 1633512445, "customer": {"id": "cus_LK&*Hli8YLIO", "discount": None, "email": None}, "collection_method": "charge_automatically", - "trial_end": None, "tax_ids": None, - }, - "checkout_session_id": None, - "name": owner.name, - "email": owner.email, - "nb_active_private_repos": 0, - "repo_total_credits": 99999999, - "plan_provider": owner.plan_provider, - "activated_student_count": 0, - "student_count": 0, - "schedule_detail": { - "id": "123", - "scheduled_phase": { - "plan": "monthly", - "quantity": schedule_params["quantity"], - "start_date": schedule_params["start_date"], - }, + "trial_end": None, }, "uses_invoice": False, - "delinquent": None, } @patch("services.billing.stripe.SubscriptionSchedule.retrieve") @@ -371,7 +377,7 @@ def test_retrieve_account_returns_last_phase_when_more_than_one_scheduled_phases "schedule_detail": { "id": "123", "scheduled_phase": { - "plan": "monthly", + "plan": "Pro", "quantity": schedule_params["quantity"], "start_date": schedule_params["start_date"], }, @@ -482,13 +488,13 @@ def test_retrieve_account_gets_account_students(self): } def test_account_with_free_user_plan(self): - self.current_owner.plan = "users-free" + self.current_owner.plan = PlanName.BASIC_PLAN_NAME.value self.current_owner.save() response = self._retrieve() assert response.status_code == status.HTTP_200_OK assert response.data["plan"] == { "marketing_name": "Developer", - "value": "users-free", + "value": PlanName.BASIC_PLAN_NAME.value, "billing_rate": None, "base_unit_price": 0, "benefits": [ @@ -500,13 +506,13 @@ def test_account_with_free_user_plan(self): } def test_account_with_paid_user_plan_billed_monthly(self): - self.current_owner.plan = "users-pr-inappm" + self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value self.current_owner.save() response = self._retrieve() assert response.status_code == status.HTTP_200_OK assert response.data["plan"] == { "marketing_name": "Pro", - "value": "users-pr-inappm", + "value": PlanName.CODECOV_PRO_MONTHLY.value, "billing_rate": "monthly", "base_unit_price": 12, "benefits": [ @@ -519,13 +525,13 @@ def test_account_with_paid_user_plan_billed_monthly(self): } def test_account_with_paid_user_plan_billed_annually(self): - self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY_LEGACY.value + self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.current_owner.save() response = self._retrieve() assert response.status_code == status.HTTP_200_OK assert response.data["plan"] == { "marketing_name": "Pro", - "value": PlanName.CODECOV_PRO_YEARLY_LEGACY.value, + "value": PlanName.CODECOV_PRO_YEARLY.value, "billing_rate": "annually", "base_unit_price": 10, "benefits": [ @@ -685,7 +691,7 @@ def test_update_can_set_plan_auto_activate_on_org_with_account(self): assert response.data["plan_auto_activate"] is False def test_update_can_set_plan_to_users_basic(self): - self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY_LEGACY.value + self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.current_owner.save() response = self._update( @@ -1496,7 +1502,7 @@ def test_update_sentry_plan_monthly_with_users_org( @patch("api.internal.owner.serializers.send_sentry_webhook") @patch("services.billing.StripeService.modify_subscription") def test_update_sentry_plan_annual(self, modify_sub_mock, send_sentry_webhook): - desired_plan = {"value": "users-sentryy", "quantity": 12} + desired_plan = {"value": PlanName.SENTRY_YEARLY.value, "quantity": 12} self.current_owner.stripe_customer_id = "flsoe" self.current_owner.stripe_subscription_id = "djfos" self.current_owner.sentry_user_id = "sentry-user-id" @@ -1518,7 +1524,7 @@ def test_update_sentry_plan_annual(self, modify_sub_mock, send_sentry_webhook): def test_update_sentry_plan_annual_with_users_org( self, modify_sub_mock, send_sentry_webhook ): - desired_plan = {"value": "users-sentryy", "quantity": 12} + desired_plan = {"value": PlanName.SENTRY_YEARLY.value, "quantity": 12} org = OwnerFactory( service=Service.GITHUB.value, service_id="923836740", @@ -1574,7 +1580,7 @@ def test_update_apply_cancellation_discount( ): coupon_create_mock.return_value = MagicMock(id="test-coupon-id") - self.current_owner.plan = "users-pr-inappm" + self.current_owner.plan = PlanName.CODECOV_PRO_MONTHLY.value self.current_owner.stripe_customer_id = "flsoe" self.current_owner.stripe_subscription_id = "djfos" self.current_owner.save() @@ -1626,7 +1632,7 @@ def test_update_apply_cancellation_discount_yearly( ): coupon_create_mock.return_value = MagicMock(id="test-coupon-id") - self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY_LEGACY.value + self.current_owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.current_owner.stripe_customer_id = "flsoe" self.current_owner.stripe_subscription_id = "djfos" self.current_owner.save() @@ -1679,7 +1685,7 @@ def test_retrieve_org_with_account(self): name="Hello World", plan_seat_count=5, free_seat_count=3, - plan="users-enterprisey", + plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value, is_delinquent=False, ) InvoiceBillingFactory(is_active=True, account=account) diff --git a/api/internal/tests/views/test_user_viewset.py b/api/internal/tests/views/test_user_viewset.py index f62bb78310..3ce2c3a89f 100644 --- a/api/internal/tests/views/test_user_viewset.py +++ b/api/internal/tests/views/test_user_viewset.py @@ -4,11 +4,13 @@ from rest_framework import status from rest_framework.reverse import reverse from rest_framework.test import APITestCase +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import ( OwnerFactory, PullFactory, RepositoryFactory, ) +from shared.plan.constants import PlanName, TierName from core.models import Pull from utils.test_utils import APIClient @@ -17,8 +19,10 @@ class UserViewSetTests(APITestCase): def setUp(self): non_org_active_user = OwnerFactory() + tier = TierFactory(tier_name=TierName.BASIC.value) + plan = PlanFactory(name=PlanName.BASIC_PLAN_NAME.value, tier=tier) self.current_owner = OwnerFactory( - plan="users-free", + plan=plan.name, plan_user_count=5, plan_activated_users=[non_org_active_user.ownerid], ) diff --git a/billing/helpers.py b/billing/helpers.py index c66e6333cb..2bccb691ce 100644 --- a/billing/helpers.py +++ b/billing/helpers.py @@ -1,14 +1,15 @@ from django.conf import settings from django.db.models import QuerySet -from shared.plan.constants import ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS +from shared.django_apps.codecov_auth.models import BillingRate +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory +from shared.plan.constants import PlanName, PlanPrice, TierName -from codecov_auth.models import Owner +from codecov_auth.models import Owner, Plan def on_enterprise_plan(owner: Owner) -> bool: - return settings.IS_ENTERPRISE or ( - owner.plan in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS.keys() - ) + plan = Plan.objects.select_related("tier").get(name=owner.plan) + return settings.IS_ENTERPRISE or (plan.tier.tier_name == TierName.ENTERPRISE.value) def get_all_admins_for_owners(owners: QuerySet[Owner]): @@ -23,3 +24,166 @@ def get_all_admins_for_owners(owners: QuerySet[Owner]): admins: QuerySet[Owner] = Owner.objects.filter(pk__in=admin_ids) return admins + + +def mock_all_plans_and_tiers(): + trial_tier = TierFactory(tier_name=TierName.TRIAL.value) + PlanFactory( + tier=trial_tier, + name=PlanName.TRIAL_PLAN_NAME.value, + paid_plan=False, + marketing_name="Developer", + benefits=[ + "Configurable # of users", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + ) + + basic_tier = TierFactory(tier_name=TierName.BASIC.value) + PlanFactory( + name=PlanName.BASIC_PLAN_NAME.value, + tier=basic_tier, + marketing_name="Developer", + benefits=[ + "Up to 1 user", + "Unlimited public repositories", + "Unlimited private repositories", + ], + monthly_uploads_limit=250, + ) + PlanFactory( + name=PlanName.FREE_PLAN_NAME.value, + tier=basic_tier, + marketing_name="Developer", + benefits=[ + "Up to 1 user", + "Unlimited public repositories", + "Unlimited private repositories", + ], + ) + + pro_tier = TierFactory(tier_name=TierName.PRO.value) + PlanFactory( + name=PlanName.CODECOV_PRO_MONTHLY.value, + tier=pro_tier, + marketing_name="Pro", + benefits=[ + "Configurable # of users", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + billing_rate=BillingRate.MONTHLY.value, + base_unit_price=PlanPrice.MONTHLY.value, + paid_plan=True, + ) + PlanFactory( + name=PlanName.CODECOV_PRO_YEARLY.value, + tier=pro_tier, + marketing_name="Pro", + benefits=[ + "Configurable # of users", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + billing_rate=BillingRate.ANNUALLY.value, + base_unit_price=PlanPrice.YEARLY.value, + paid_plan=True, + ) + + team_tier = TierFactory(tier_name=TierName.TEAM.value) + PlanFactory( + name=PlanName.TEAM_MONTHLY.value, + tier=team_tier, + marketing_name="Team", + benefits=[ + "Up to 10 users", + "Unlimited repositories", + "2500 private repo uploads", + "Patch coverage analysis", + ], + billing_rate=BillingRate.MONTHLY.value, + base_unit_price=PlanPrice.TEAM_MONTHLY.value, + monthly_uploads_limit=2500, + paid_plan=True, + ) + PlanFactory( + name=PlanName.TEAM_YEARLY.value, + tier=team_tier, + marketing_name="Team", + benefits=[ + "Up to 10 users", + "Unlimited repositories", + "2500 private repo uploads", + "Patch coverage analysis", + ], + billing_rate=BillingRate.ANNUALLY.value, + base_unit_price=PlanPrice.TEAM_YEARLY.value, + monthly_uploads_limit=2500, + paid_plan=True, + ) + + sentry_tier = TierFactory(tier_name=TierName.SENTRY.value) + PlanFactory( + name=PlanName.SENTRY_MONTHLY.value, + tier=sentry_tier, + marketing_name="Sentry Pro", + billing_rate=BillingRate.MONTHLY.value, + base_unit_price=PlanPrice.MONTHLY.value, + paid_plan=True, + benefits=[ + "Includes 5 seats", + "$12 per additional seat", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + ) + PlanFactory( + name=PlanName.SENTRY_YEARLY.value, + tier=sentry_tier, + marketing_name="Sentry Pro", + billing_rate=BillingRate.ANNUALLY.value, + base_unit_price=PlanPrice.YEARLY.value, + paid_plan=True, + benefits=[ + "Includes 5 seats", + "$10 per additional seat", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + ) + + enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value) + PlanFactory( + name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value, + tier=enterprise_tier, + marketing_name="Enterprise Cloud", + billing_rate=BillingRate.MONTHLY.value, + base_unit_price=PlanPrice.MONTHLY.value, + paid_plan=True, + benefits=[ + "Configurable # of users", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + ) + PlanFactory( + name=PlanName.ENTERPRISE_CLOUD_YEARLY.value, + tier=enterprise_tier, + marketing_name="Enterprise Cloud", + billing_rate=BillingRate.ANNUALLY.value, + base_unit_price=PlanPrice.YEARLY.value, + paid_plan=True, + benefits=[ + "Configurable # of users", + "Unlimited public repositories", + "Unlimited private repositories", + "Priority Support", + ], + ) diff --git a/billing/tests/test_helpers.py b/billing/tests/test_helpers.py index f60974de34..f862b782e1 100644 --- a/billing/tests/test_helpers.py +++ b/billing/tests/test_helpers.py @@ -1,18 +1,28 @@ from django.test import TestCase, override_settings -from shared.django_apps.core.tests.factories import OwnerFactory -from shared.plan.constants import ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS +from shared.django_apps.codecov_auth.tests.factories import OwnerFactory +from shared.plan.constants import PlanName -from billing.helpers import on_enterprise_plan +from billing.helpers import mock_all_plans_and_tiers, on_enterprise_plan class HelpersTestCase(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + @override_settings(IS_ENTERPRISE=True) def test_on_enterprise_plan_on_prem(self): owner = OwnerFactory() assert on_enterprise_plan(owner) == True - def test_on_enterprise_plan_enterpise_cloud(self): - for plan in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS.keys(): + def test_on_enterprise_plan_enterprise_cloud(self): + plan_names = [ + PlanName.ENTERPRISE_CLOUD_MONTHLY.value, + PlanName.ENTERPRISE_CLOUD_YEARLY.value, + ] + + for plan in plan_names: owner = OwnerFactory(plan=plan) assert on_enterprise_plan(owner) == True diff --git a/billing/tests/test_views.py b/billing/tests/test_views.py index d1fbcc8da8..ce05c6e911 100644 --- a/billing/tests/test_views.py +++ b/billing/tests/test_views.py @@ -11,6 +11,8 @@ from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory from shared.plan.constants import PlanName +from billing.helpers import mock_all_plans_and_tiers + from ..constants import StripeHTTPHeaders @@ -69,9 +71,15 @@ def __getitem__(self, key): class StripeWebhookHandlerTests(APITestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + def setUp(self): self.owner = OwnerFactory( - stripe_customer_id="cus_123", stripe_subscription_id="sub_123" + stripe_customer_id="cus_123", + stripe_subscription_id="sub_123", ) # Creates a second owner that shares billing details with self.owner. @@ -79,7 +87,8 @@ def setUp(self): # subscription in Stripe. def add_second_owner(self): self.other_owner = OwnerFactory( - stripe_customer_id="cus_123", stripe_subscription_id="sub_123" + stripe_customer_id="cus_123", + stripe_subscription_id="sub_123", ) def _send_event(self, payload, errorSig=None): @@ -467,7 +476,7 @@ def test_invoice_payment_failed_sends_email_to_admins_no_card( mocked_send_email.assert_has_calls(expected_calls) def test_customer_subscription_deleted_sets_plan_to_free(self): - self.owner.plan = "users-inappy" + self.owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.owner.plan_user_count = 20 self.owner.save() @@ -492,10 +501,10 @@ def test_customer_subscription_deleted_sets_plan_to_free(self): def test_customer_subscription_deleted_sets_plan_to_free_mutliple_owner(self): self.add_second_owner() - self.owner.plan = "users-inappy" + self.owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.owner.plan_user_count = 20 self.owner.save() - self.other_owner.plan = "users-inappy" + self.other_owner.plan = PlanName.CODECOV_PRO_YEARLY.value self.other_owner.plan_user_count = 20 self.other_owner.save() @@ -540,7 +549,7 @@ def test_customer_subscription_deleted_deactivates_all_repos(self): "object": { "id": self.owner.stripe_subscription_id, "customer": self.owner.stripe_customer_id, - "plan": {"name": "users-inappm"}, + "plan": {"name": PlanName.CODECOV_PRO_MONTHLY.value}, } }, } @@ -577,7 +586,7 @@ def test_customer_subscription_deleted_deactivates_all_repos_multiple_owner(self "object": { "id": self.owner.stripe_subscription_id, "customer": self.owner.stripe_customer_id, - "plan": {"name": "users-inappm"}, + "plan": {"name": PlanName.CODECOV_PRO_MONTHLY.value}, } }, } @@ -598,7 +607,7 @@ def test_customer_subscription_deleted_deactivates_all_repos_multiple_owner(self @patch("logging.Logger.info") def test_customer_subscription_deleted_no_customer(self, log_info_mock): - self.owner.plan = "users-inappy" + self.owner.plan = PlanName.CODECOV_PRO_MONTHLY.value self.owner.plan_user_count = 20 self.owner.save() @@ -688,7 +697,7 @@ def test_customer_subscription_created_sets_plan_info(self): stripe_subscription_id = "FOEKDCDEQ" stripe_customer_id = "sdo050493" - plan_name = "users-pr-inappy" + plan_name = PlanName.CODECOV_PRO_YEARLY.value quantity = 20 self._send_event( diff --git a/codecov_auth/admin.py b/codecov_auth/admin.py index f07cebceb0..e9771f009d 100644 --- a/codecov_auth/admin.py +++ b/codecov_auth/admin.py @@ -21,7 +21,6 @@ StripeBilling, Tier, ) -from shared.plan.constants import USER_PLAN_REPRESENTATIONS from shared.plan.service import PlanService from codecov.admin import AdminMixin @@ -609,7 +608,10 @@ class OwnerAdmin(AdminMixin, admin.ModelAdmin): def get_form(self, request, obj=None, change=False, **kwargs): form = super().get_form(request, obj, change, **kwargs) - PLANS_CHOICES = [(x, x) for x in USER_PLAN_REPRESENTATIONS.keys()] + PLANS_CHOICES = [ + (x, x) + for x in Plan.objects.filter(is_active=True).values_list("name", flat=True) + ] form.base_fields["plan"].widget = Select( choices=BLANK_CHOICE_DASH + PLANS_CHOICES ) diff --git a/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py b/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py index 138b04f64e..6aa62d59f8 100644 --- a/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py +++ b/codecov_auth/commands/owner/interactors/tests/test_cancel_trial.py @@ -5,8 +5,9 @@ from django.test import TransactionTestCase from freezegun import freeze_time from shared.django_apps.codecov.commands.exceptions import ValidationError +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import OwnerFactory -from shared.plan.constants import PlanName, TrialStatus +from shared.plan.constants import PlanName, TierName, TrialStatus from codecov.commands.exceptions import Unauthorized from codecov.commands.exceptions import ValidationError as CodecovValidationError @@ -16,6 +17,10 @@ class CancelTrialInteractorTest(TransactionTestCase): + def setUp(self): + self.tier = TierFactory(tier_name=TierName.BASIC.value) + self.plan = PlanFactory(tier=self.tier) + @async_to_sync def execute(self, current_user, org_username=None): current_user = current_user @@ -27,6 +32,7 @@ def test_cancel_trial_raises_exception_when_owner_is_not_in_db(self): current_user = OwnerFactory( username="random-user-123", service="github", + plan=self.plan.name, ) with pytest.raises(CodecovValidationError): self.execute(current_user=current_user, org_username="some-other-username") @@ -35,10 +41,12 @@ def test_cancel_trial_raises_exception_when_current_user_not_part_of_org(self): current_user = OwnerFactory( username="random-user-123", service="github", + plan=self.plan.name, ) OwnerFactory( username="random-user-456", service="github", + plan=self.plan.name, ) with pytest.raises(Unauthorized): self.execute(current_user=current_user, org_username="random-user-456") @@ -54,6 +62,7 @@ def test_cancel_trial_raises_exception_when_owners_trial_status_is_not_started( service="github", trial_start_date=trial_start_date, trial_end_date=trial_end_date, + plan=self.plan.name, ) with pytest.raises(ValidationError): self.execute(current_user=current_user, org_username=current_user.username) @@ -68,6 +77,7 @@ def test_cancel_trial_raises_exception_when_owners_trial_status_is_expired(self) service="github", trial_start_date=trial_start_date, trial_end_date=trial_end_date, + plan=self.plan.name, ) with pytest.raises(ValidationError): self.execute(current_user=current_user, org_username=current_user.username) @@ -77,13 +87,15 @@ def test_cancel_trial_starts_trial_for_org_that_has_trial_ongoing(self): now = datetime.now() trial_start_date = now trial_end_date = now + timedelta(days=3) + trial_tier = TierFactory(tier_name=TierName.TRIAL.value) + trial_plan = PlanFactory(tier=trial_tier, name=PlanName.TRIAL_PLAN_NAME.value) current_user: Owner = OwnerFactory( username="random-user-123", service="github", trial_start_date=trial_start_date, trial_end_date=trial_end_date, trial_status=TrialStatus.ONGOING.value, - plan=PlanName.TRIAL_PLAN_NAME.value, + plan=trial_plan.name, ) self.execute(current_user=current_user, org_username=current_user.username) current_user.refresh_from_db() diff --git a/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py b/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py index 39eb4596e2..2b4cf1fd56 100644 --- a/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py +++ b/codecov_auth/commands/owner/interactors/tests/test_get_uploads_number_per_user.py @@ -1,13 +1,14 @@ from datetime import datetime, timedelta from django.test import TransactionTestCase +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import ( CommitFactory, OwnerFactory, RepositoryFactory, ) from shared.django_apps.reports.models import ReportType -from shared.plan.constants import TrialStatus +from shared.plan.constants import PlanName, TierName, TrialStatus from shared.upload.utils import UploaderType, insert_coverage_measurement from reports.tests.factories import CommitReportFactory, UploadFactory @@ -17,8 +18,10 @@ class GetUploadsNumberPerUserInteractorTest(TransactionTestCase): def setUp(self): - self.user_with_no_uploads = OwnerFactory() - self.user_with_uploads = OwnerFactory() + self.tier = TierFactory(tier_name=TierName.BASIC.value) + self.plan = PlanFactory(tier=self.tier, monthly_uploads_limit=250) + self.user_with_no_uploads = OwnerFactory(plan=self.plan.name) + self.user_with_uploads = OwnerFactory(plan=self.plan.name) repo = RepositoryFactory.create(author=self.user_with_uploads, private=True) commit = CommitFactory.create(repository=repo) report = CommitReportFactory.create( @@ -44,10 +47,17 @@ def setUp(self): report_within_40_days.save() # Trial Data + trial_tier = TierFactory(tier_name=TierName.TRIAL.value) + trial_plan = PlanFactory( + tier=trial_tier, + name=PlanName.TRIAL_PLAN_NAME.value, + monthly_uploads_limit=250, + ) self.trial_owner = OwnerFactory( trial_status=TrialStatus.EXPIRED.value, trial_start_date=datetime.now() + timedelta(days=-10), trial_end_date=datetime.now() + timedelta(days=-2), + plan=trial_plan.name, ) trial_repo = RepositoryFactory.create(author=self.trial_owner, private=True) trial_commit = CommitFactory.create(repository=trial_repo) diff --git a/codecov_auth/commands/owner/interactors/tests/test_start_trial.py b/codecov_auth/commands/owner/interactors/tests/test_start_trial.py index 3504e9d527..a95562c42a 100644 --- a/codecov_auth/commands/owner/interactors/tests/test_start_trial.py +++ b/codecov_auth/commands/owner/interactors/tests/test_start_trial.py @@ -5,10 +5,12 @@ from django.test import TransactionTestCase from freezegun import freeze_time from shared.django_apps.codecov.commands.exceptions import ValidationError +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import OwnerFactory from shared.plan.constants import ( TRIAL_PLAN_SEATS, PlanName, + TierName, TrialDaysAmount, TrialStatus, ) @@ -21,6 +23,10 @@ class StartTrialInteractorTest(TransactionTestCase): + def setUp(self): + self.tier = TierFactory(tier_name=TierName.BASIC.value) + self.plan = PlanFactory(tier=self.tier, is_active=True) + @async_to_sync def execute(self, current_user, org_username=None): current_user = current_user @@ -32,6 +38,7 @@ def test_start_trial_raises_exception_when_owner_is_not_in_db(self): current_user = OwnerFactory( username="random-user-123", service="github", + plan=self.plan.name, ) with pytest.raises(CodecovValidationError): self.execute(current_user=current_user, org_username="some-other-username") @@ -40,10 +47,12 @@ def test_cancel_trial_raises_exception_when_current_user_not_part_of_org(self): current_user = OwnerFactory( username="random-user-123", service="github", + plan=self.plan.name, ) OwnerFactory( username="random-user-456", service="github", + plan=self.plan.name, ) with pytest.raises(Unauthorized): self.execute(current_user=current_user, org_username="random-user-456") @@ -59,6 +68,7 @@ def test_start_trial_raises_exception_when_owners_trial_status_is_ongoing(self): trial_start_date=trial_start_date, trial_end_date=trial_end_date, trial_status=TrialStatus.ONGOING.value, + plan=self.plan.name, ) with pytest.raises(ValidationError): self.execute(current_user=current_user, org_username=current_user.username) @@ -74,6 +84,7 @@ def test_start_trial_raises_exception_when_owners_trial_status_is_expired(self): trial_start_date=trial_start_date, trial_end_date=trial_end_date, trial_status=TrialStatus.EXPIRED.value, + plan=self.plan.name, ) with pytest.raises(ValidationError): self.execute(current_user=current_user, org_username=current_user.username) @@ -91,6 +102,7 @@ def test_start_trial_raises_exception_when_owners_trial_status_cannot_trial( trial_start_date=trial_start_date, trial_end_date=trial_end_date, trial_status=TrialStatus.CANNOT_TRIAL.value, + plan=self.plan.name, ) with pytest.raises(ValidationError): self.execute(current_user=current_user, org_username=current_user.username) @@ -105,6 +117,7 @@ def test_start_trial_starts_trial_for_org_that_has_not_started_trial_before_and_ trial_start_date=None, trial_end_date=None, trial_status=TrialStatus.NOT_STARTED.value, + plan=self.plan.name, ) self.execute(current_user=current_user, org_username=current_user.username) current_user.refresh_from_db() diff --git a/codecov_auth/services/org_level_token_service.py b/codecov_auth/services/org_level_token_service.py index 9cba8b48dc..bb20bfb36d 100644 --- a/codecov_auth/services/org_level_token_service.py +++ b/codecov_auth/services/org_level_token_service.py @@ -4,9 +4,8 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.forms import ValidationError -from shared.plan.constants import USER_PLAN_REPRESENTATIONS -from codecov_auth.models import OrganizationLevelToken, Owner +from codecov_auth.models import OrganizationLevelToken, Owner, Plan log = logging.getLogger(__name__) @@ -20,7 +19,7 @@ class OrgLevelTokenService(object): @classmethod def org_can_have_upload_token(cls, org: Owner): - return org.plan in USER_PLAN_REPRESENTATIONS + return org.plan in Plan.objects.values_list("name", flat=True) @classmethod def get_or_create_org_token(cls, org: Owner): diff --git a/codecov_auth/tests/test_admin.py b/codecov_auth/tests/test_admin.py index bd9d0ca4e6..ec40c11f0d 100644 --- a/codecov_auth/tests/test_admin.py +++ b/codecov_auth/tests/test_admin.py @@ -31,6 +31,7 @@ PlanName, ) +from billing.helpers import mock_all_plans_and_tiers from codecov.commands.exceptions import ValidationError from codecov_auth.admin import ( AccountAdmin, @@ -53,6 +54,11 @@ class OwnerAdminTest(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + def setUp(self): self.staff_user = UserFactory(is_staff=True) self.client.force_login(user=self.staff_user) @@ -68,8 +74,10 @@ def test_owner_admin_detail_page(self): self.assertEqual(response.status_code, 200) def test_owner_admin_impersonate_owner(self): - owner_to_impersonate = OwnerFactory(service="bitbucket") - other_owner = OwnerFactory() + owner_to_impersonate = OwnerFactory( + service="bitbucket", plan=PlanName.BASIC_PLAN_NAME.value + ) + other_owner = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) with self.subTest("more than one user selected"): response = self.client.post( @@ -103,7 +111,7 @@ def test_owner_admin_impersonate_owner(self): @patch("codecov_auth.admin.TaskService.delete_owner") def test_delete_queryset(self, delete_mock): - user_to_delete = OwnerFactory() + user_to_delete = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) ownerid = user_to_delete.ownerid queryset = MagicMock() queryset.__iter__.return_value = [user_to_delete] @@ -114,14 +122,14 @@ def test_delete_queryset(self, delete_mock): @patch("codecov_auth.admin.TaskService.delete_owner") def test_delete_model(self, delete_mock): - user_to_delete = OwnerFactory() + user_to_delete = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) ownerid = user_to_delete.ownerid self.owner_admin.delete_model(MagicMock(), user_to_delete) delete_mock.assert_called_once_with(ownerid=ownerid) @patch("codecov_auth.admin.admin.ModelAdmin.get_deleted_objects") def test_confirmation_deleted_objects(self, mocked_deleted_objs): - user_to_delete = OwnerFactory() + user_to_delete = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) deleted_objs = [ 'Owner: {};'.format( user_to_delete.ownerid, user_to_delete @@ -141,7 +149,7 @@ def test_confirmation_deleted_objects(self, mocked_deleted_objs): @patch("codecov_auth.admin.admin.ModelAdmin.log_change") def test_prev_and_new_values_in_log_entry(self, mocked_super_log_change): - owner = OwnerFactory(staff=True) + owner = OwnerFactory(staff=True, plan=PlanName.BASIC_PLAN_NAME.value) owner.save() owner.staff = False form = MagicMock() @@ -161,7 +169,7 @@ def test_prev_and_new_values_in_log_entry(self, mocked_super_log_change): ] def test_inline_orgwide_tokens_display(self): - owner = OwnerFactory() + owner = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) request_url = reverse("admin:codecov_auth_owner_change", args=[owner.ownerid]) request = RequestFactory().get(request_url) request.user = self.staff_user @@ -170,7 +178,7 @@ def test_inline_orgwide_tokens_display(self): assert isinstance(inlines[0], OrgUploadTokenInline) def test_inline_orgwide_permissions(self): - owner_in_cloud_plan = OwnerFactory(plan="users-enterprisey") + owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value) org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan) owner_in_cloud_plan.save() org_token.save() @@ -194,7 +202,7 @@ def test_inline_orgwide_permissions(self): def test_inline_orgwide_add_token_permission_no_token_and_user_in_enterprise_cloud_plan( self, ): - owner = OwnerFactory() + owner = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) assert owner.plan not in ENTERPRISE_CLOUD_USER_PLAN_REPRESENTATIONS assert OrganizationLevelToken.objects.filter(owner=owner).count() == 0 request_url = reverse("admin:codecov_auth_owner_change", args=[owner.ownerid]) @@ -207,7 +215,7 @@ def test_inline_orgwide_add_token_permission_no_token_and_user_in_enterprise_clo def test_inline_orgwide_add_token_permission_no_token_user_not_in_enterprise_cloud_plan( self, ): - owner_in_cloud_plan = OwnerFactory(plan="users-enterprisey") + owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value) assert ( OrganizationLevelToken.objects.filter(owner=owner_in_cloud_plan).count() == 0 @@ -227,7 +235,7 @@ def test_inline_orgwide_add_token_permission_no_token_user_not_in_enterprise_clo def test_org_token_refresh_request_calls_service_to_refresh_token( self, mock_refresh ): - owner_in_cloud_plan = OwnerFactory(plan="users-enterprisey") + owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value) org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan) owner_in_cloud_plan.save() org_token.save() @@ -264,7 +272,7 @@ def test_org_token_refresh_request_calls_service_to_refresh_token( "codecov_auth.services.org_level_token_service.OrgLevelTokenService.refresh_token" ) def test_org_token_request_doesnt_call_service_to_refresh_token(self, mock_refresh): - owner_in_cloud_plan = OwnerFactory(plan="users-enterprisey") + owner_in_cloud_plan = OwnerFactory(plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value) org_token = OrganizationLevelTokenFactory(owner=owner_in_cloud_plan) owner_in_cloud_plan.save() org_token.save() @@ -297,7 +305,7 @@ def test_org_token_request_doesnt_call_service_to_refresh_token(self, mock_refre mock_refresh.assert_not_called() def test_start_trial_ui_display(self): - owner = OwnerFactory() + owner = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) res = self.client.post( reverse("admin:codecov_auth_owner_changelist"), @@ -312,7 +320,7 @@ def test_start_trial_ui_display(self): @patch("shared.plan.service.PlanService.start_trial_manually") def test_start_trial_action(self, mock_start_trial_service): mock_start_trial_service.return_value = None - org_to_be_trialed = OwnerFactory() + org_to_be_trialed = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) res = self.client.post( reverse("admin:codecov_auth_owner_changelist"), @@ -329,7 +337,7 @@ def test_start_trial_action(self, mock_start_trial_service): @patch("shared.plan.service.PlanService._start_trial_helper") def test_extend_trial_action(self, mock_start_trial_service): mock_start_trial_service.return_value = None - org_to_be_trialed = OwnerFactory() + org_to_be_trialed = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) org_to_be_trialed.plan = PlanName.TRIAL_PLAN_NAME.value org_to_be_trialed.save() @@ -352,7 +360,7 @@ def test_start_trial_paid_plan(self, mock_start_trial_service): "Cannot trial from a paid plan" ) - org_to_be_trialed = OwnerFactory() + org_to_be_trialed = OwnerFactory(plan=PlanName.BASIC_PLAN_NAME.value) res = self.client.post( reverse("admin:codecov_auth_owner_changelist"), @@ -367,7 +375,9 @@ def test_start_trial_paid_plan(self, mock_start_trial_service): assert mock_start_trial_service.called def test_account_widget(self): - owner = OwnerFactory(user=UserFactory(), plan="users-enterprisey") + owner = OwnerFactory( + user=UserFactory(), plan=PlanName.ENTERPRISE_CLOUD_YEARLY.value + ) rf = RequestFactory() get_request = rf.get(f"/admin/codecov_auth/owner/{owner.ownerid}/change/") get_request.user = self.staff_user @@ -899,30 +909,28 @@ def setUp(self): admin_site = AdminSite() admin_site.register(Plan) + self.tier = TierFactory() + self.plan = PlanFactory(name=PlanName.BASIC_PLAN_NAME.value, tier=self.tier) + def test_plan_admin_modal_display(self): - plan = PlanFactory() response = self.client.get( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]) + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]) ) self.assertEqual(response.status_code, 200) - self.assertContains(response, plan.name) + self.assertContains(response, self.plan.name) def test_plan_modal_tiers_display(self): - tier = TierFactory() - plan = PlanFactory(tier=tier) response = self.client.get( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]) + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]) ) self.assertEqual(response.status_code, 200) - self.assertContains(response, tier.tier_name) + self.assertContains(response, self.tier.tier_name) def test_add_plans_modal_action(self): - plan = PlanFactory(base_unit_price=10, max_seats=5) - tier = TierFactory() data = { "action": "add_plans", - ACTION_CHECKBOX_NAME: [plan.pk], - "tier_id": tier.pk, + ACTION_CHECKBOX_NAME: [self.plan.pk], + "tier_id": self.tier.pk, } response = self.client.post( reverse("admin:codecov_auth_plan_changelist"), data=data @@ -931,9 +939,8 @@ def test_add_plans_modal_action(self): self.assertEqual(response.url, "/admin/codecov_auth/plan/") def test_plan_change_form(self): - plan = PlanFactory() response = self.client.get( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]) + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]) ) self.assertEqual(response.status_code, 200) for field in [ @@ -951,49 +958,50 @@ def test_plan_change_form(self): self.assertContains(response, f"id_{field}") def test_plan_change_form_validation(self): - plan = PlanFactory(base_unit_price=-10) + self.plan.base_unit_price = -10 + self.plan.save() response = self.client.post( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]), + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]), { - "tier": plan.tier_id, - "name": plan.name, - "marketing_name": plan.marketing_name, + "tier": self.tier.pk, + "name": self.plan.name, + "marketing_name": self.plan.marketing_name, "base_unit_price": -10, - "benefits": plan.benefits, - "is_active": plan.is_active, - "paid_plan": plan.paid_plan, + "benefits": self.plan.benefits, + "is_active": self.plan.is_active, + "paid_plan": self.plan.paid_plan, }, ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Base unit price cannot be negative.") response = self.client.post( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]), + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]), { - "tier": plan.tier_id, - "name": plan.name, - "marketing_name": plan.marketing_name, - "base_unit_price": plan.base_unit_price, - "benefits": plan.benefits, - "is_active": plan.is_active, + "tier": self.tier.pk, + "name": self.plan.name, + "marketing_name": self.plan.marketing_name, + "base_unit_price": self.plan.base_unit_price, + "benefits": self.plan.benefits, + "is_active": self.plan.is_active, "max_seats": -5, - "paid_plan": plan.paid_plan, + "paid_plan": self.plan.paid_plan, }, ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Max seats cannot be negative.") response = self.client.post( - reverse("admin:codecov_auth_plan_change", args=[plan.pk]), + reverse("admin:codecov_auth_plan_change", args=[self.plan.pk]), { - "tier": plan.tier_id, - "name": plan.name, - "marketing_name": plan.marketing_name, - "benefits": plan.benefits, - "is_active": plan.is_active, + "tier": self.tier.pk, + "name": self.plan.name, + "marketing_name": self.plan.marketing_name, + "benefits": self.plan.benefits, + "is_active": self.plan.is_active, "monthly_uploads_limit": -5, - "paid_plan": plan.paid_plan, + "paid_plan": self.plan.paid_plan, }, ) self.assertEqual(response.status_code, 200) @@ -1007,21 +1015,21 @@ def setUp(self): admin_site = AdminSite() admin_site.register(Tier) + self.tier = TierFactory() + self.plan = PlanFactory(name=PlanName.BASIC_PLAN_NAME.value, tier=self.tier) + def test_tier_modal_plans_display(self): - tier = TierFactory() response = self.client.get( - reverse("admin:codecov_auth_tier_change", args=[tier.pk]) + reverse("admin:codecov_auth_tier_change", args=[self.tier.pk]) ) self.assertEqual(response.status_code, 200) - self.assertContains(response, tier.tier_name) + self.assertContains(response, self.tier.tier_name) def test_add_plans_modal_action(self): - tier = TierFactory() - plan = PlanFactory() data = { "action": "add_plans", - ACTION_CHECKBOX_NAME: [plan.pk], - "tier_id": tier.pk, + ACTION_CHECKBOX_NAME: [self.plan.pk], + "tier_id": self.tier.pk, } response = self.client.post( reverse("admin:codecov_auth_tier_changelist"), data=data @@ -1030,9 +1038,8 @@ def test_add_plans_modal_action(self): self.assertEqual(response.url, "/admin/codecov_auth/tier/") def test_tier_change_form(self): - tier = TierFactory() response = self.client.get( - reverse("admin:codecov_auth_tier_change", args=[tier.pk]) + reverse("admin:codecov_auth_tier_change", args=[self.tier.pk]) ) self.assertEqual(response.status_code, 200) for field in [ diff --git a/codecov_auth/tests/unit/services/test_org_level_token_service.py b/codecov_auth/tests/unit/services/test_org_level_token_service.py index 16fc46daea..1c5d423c69 100644 --- a/codecov_auth/tests/unit/services/test_org_level_token_service.py +++ b/codecov_auth/tests/unit/services/test_org_level_token_service.py @@ -7,7 +7,10 @@ from shared.django_apps.codecov_auth.tests.factories import ( OrganizationLevelTokenFactory, OwnerFactory, + PlanFactory, + TierFactory, ) +from shared.plan.constants import PlanName, TierName from codecov_auth.models import OrganizationLevelToken from codecov_auth.services.org_level_token_service import OrgLevelTokenService @@ -20,7 +23,11 @@ def test_token_is_deleted_when_changing_user_plan(mocked_org_can_have_upload_tok # This should happen because of the signal consumer we have defined in # codecov_auth/services/org_upload_token_service.py > manage_org_tokens_if_owner_plan_changed mocked_org_can_have_upload_token.return_value = False - owner = OwnerFactory(plan="users-enterprisey") + enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value) + enterprise_plan = PlanFactory( + tier=enterprise_tier, name=PlanName.ENTERPRISE_CLOUD_YEARLY.value + ) + owner = OwnerFactory(plan=enterprise_plan.name) org_token = OrganizationLevelTokenFactory(owner=owner) owner.save() org_token.save() @@ -31,21 +38,33 @@ def test_token_is_deleted_when_changing_user_plan(mocked_org_can_have_upload_tok class TestOrgWideUploadTokenService(TransactionTestCase): + def setUp(self): + self.enterprise_tier = TierFactory(tier_name=TierName.ENTERPRISE.value) + self.enterprise_plan = PlanFactory( + tier=self.enterprise_tier, + name=PlanName.ENTERPRISE_CLOUD_YEARLY.value, + ) + self.basic_tier = TierFactory(tier_name=TierName.BASIC.value) + self.basic_plan = PlanFactory( + tier=self.basic_tier, + name=PlanName.BASIC_PLAN_NAME.value, + ) + self.owner = OwnerFactory(plan=self.enterprise_plan.name) + def test_get_org_token(self): # Check that if you try to create a token for an org that already has one you get the same token - owner = OwnerFactory(plan="users-enterprisey") - org_token = OrganizationLevelTokenFactory(owner=owner) - owner.save() + org_token = OrganizationLevelTokenFactory(owner=self.owner) + self.owner.save() org_token.save() - assert org_token == OrgLevelTokenService.get_or_create_org_token(owner) + assert org_token == OrgLevelTokenService.get_or_create_org_token(self.owner) def test_create_org_token(self): - user_in_enterprise_plan = OwnerFactory(plan="users-enterprisey") + user_in_enterprise_plan = OwnerFactory(plan=self.enterprise_plan.name) token = OrgLevelTokenService.get_or_create_org_token(user_in_enterprise_plan) assert isinstance(token.token, uuid.UUID) assert token.owner == user_in_enterprise_plan # Check that users not in enterprise plan can create org tokens - user_not_in_enterprise_plan = OwnerFactory(plan="users-basic") + user_not_in_enterprise_plan = OwnerFactory(plan=self.basic_plan.name) token = OrgLevelTokenService.get_or_create_org_token( user_not_in_enterprise_plan ) @@ -53,13 +72,13 @@ def test_create_org_token(self): assert token.owner == user_not_in_enterprise_plan def test_delete_token(self): - owner = OwnerFactory(plan="users-enterprisey") + owner = OwnerFactory(plan=self.enterprise_plan.name) OrgLevelTokenService.delete_org_token_if_exists(owner) with pytest.raises(OrganizationLevelToken.DoesNotExist): OrganizationLevelToken.objects.get(owner=owner) def test_refresh_token(self): - owner = OwnerFactory(plan="users-enterprisey") + owner = OwnerFactory(plan=self.enterprise_plan.name) org_token = OrganizationLevelTokenFactory(owner=owner) owner.save() org_token.save() diff --git a/graphql_api/tests/mutation/test_cancel_trial.py b/graphql_api/tests/mutation/test_cancel_trial.py index 8ceab771e5..cb5d39dc8a 100644 --- a/graphql_api/tests/mutation/test_cancel_trial.py +++ b/graphql_api/tests/mutation/test_cancel_trial.py @@ -1,7 +1,8 @@ from django.test import TransactionTestCase from prometheus_client import REGISTRY +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import OwnerFactory -from shared.plan.constants import PlanName, TrialStatus +from shared.plan.constants import PlanName, TierName, TrialStatus from graphql_api.tests.helper import GraphQLTestHelper from graphql_api.views import GQL_ERROR_COUNTER, GQL_HIT_COUNTER, GQL_REQUEST_LATENCIES @@ -61,9 +62,9 @@ def test_authenticated(self): labels={"operation_type": "mutation", "operation_name": "CancelTrialInput"}, ) trial_status = TrialStatus.ONGOING.value - owner = OwnerFactory( - trial_status=trial_status, plan=PlanName.TRIAL_PLAN_NAME.value - ) + tier = TierFactory(tier_name=TierName.TRIAL.value) + plan = PlanFactory(name=PlanName.TRIAL_PLAN_NAME.value, tier=tier) + owner = OwnerFactory(trial_status=trial_status, plan=plan.name) owner.save() assert self._request(owner=owner, org_username=owner.username) == { "cancelTrial": None diff --git a/graphql_api/tests/test_owner.py b/graphql_api/tests/test_owner.py index b626a07da6..7a1546bdc5 100644 --- a/graphql_api/tests/test_owner.py +++ b/graphql_api/tests/test_owner.py @@ -23,6 +23,7 @@ from shared.plan.constants import PlanName, TrialStatus from shared.upload.utils import UploaderType, insert_coverage_measurement +from billing.helpers import mock_all_plans_and_tiers from codecov.commands.exceptions import ( MissingService, UnauthorizedGuestAccess, @@ -59,6 +60,7 @@ class TestOwnerType(GraphQLTestHelper, TransactionTestCase): def setUp(self): + mock_all_plans_and_tiers() self.account = AccountFactory() self.owner = OwnerFactory( username="codecov-user", service="github", account=self.account @@ -1124,7 +1126,7 @@ def test_fetch_available_plans_is_enterprise_plan(self): current_org = OwnerFactory( username="random-plan-user", service="github", - plan=PlanName.FREE_PLAN_NAME.value, + plan=PlanName.BASIC_PLAN_NAME.value, ) query = """{ @@ -1154,15 +1156,6 @@ def test_fetch_available_plans_is_enterprise_plan(self): "isFreePlan": True, "isTrialPlan": False, }, - { - "value": "users-free", - "isEnterprisePlan": False, - "isProPlan": False, - "isTeamPlan": False, - "isSentryPlan": False, - "isFreePlan": True, - "isTrialPlan": False, - }, { "value": "users-pr-inappm", "isEnterprisePlan": False, diff --git a/graphql_api/tests/test_plan.py b/graphql_api/tests/test_plan.py index 371b50ea65..cac22a7914 100644 --- a/graphql_api/tests/test_plan.py +++ b/graphql_api/tests/test_plan.py @@ -11,6 +11,8 @@ from shared.plan.constants import PlanName, TrialStatus from shared.utils.test_utils import mock_config_helper +from billing.helpers import mock_all_plans_and_tiers + from .helper import GraphQLTestHelper @@ -20,6 +22,7 @@ def inject_mocker(request, mocker): request.mocker = mocker def setUp(self): + mock_all_plans_and_tiers() self.current_org = OwnerFactory( username="random-plan-user", service="github", @@ -72,10 +75,10 @@ def test_owner_plan_data_when_trialing(self): "trialStatus": "ONGOING", "trialEndDate": "2023-07-03T00:00:00", "trialStartDate": "2023-06-19T00:00:00", - "trialTotalDays": None, + "trialTotalDays": 14, "marketingName": "Developer", "value": "users-trial", - "tierName": "pro", + "tierName": "trial", "billingRate": None, "baseUnitPrice": 0, "benefits": [ diff --git a/graphql_api/tests/test_plan_representation.py b/graphql_api/tests/test_plan_representation.py index 2974b4ccee..760d7f3709 100644 --- a/graphql_api/tests/test_plan_representation.py +++ b/graphql_api/tests/test_plan_representation.py @@ -3,29 +3,39 @@ from django.test import TransactionTestCase from django.utils import timezone from freezegun import freeze_time +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import OwnerFactory -from shared.plan.constants import PlanName, TrialStatus +from shared.plan.constants import PlanName, TierName, TrialStatus from .helper import GraphQLTestHelper class TestPlanRepresentationsType(GraphQLTestHelper, TransactionTestCase): def setUp(self): + self.tier = TierFactory(tier_name=TierName.BASIC.value) + self.plan = PlanFactory(tier=self.tier, is_active=True) self.current_org = OwnerFactory( username="random-plan-user", service="github", trial_start_date=timezone.now(), trial_end_date=timezone.now() + timedelta(days=14), + plan=self.plan.name, ) @freeze_time("2023-06-19") def test_owner_pretrial_plan_data_when_trialing(self): now = timezone.now() later = timezone.now() + timedelta(days=14) + trial_tier = TierFactory(tier_name=TierName.TRIAL.value) + trial_plan = PlanFactory( + tier=trial_tier, + is_active=True, + name=PlanName.TRIAL_PLAN_NAME.value, + ) current_org = OwnerFactory( username="random-plan-user", service="github", - plan=PlanName.TRIAL_PLAN_NAME.value, + plan=trial_plan.name, trial_start_date=now, trial_end_date=later, trial_status=TrialStatus.ONGOING.value, @@ -46,14 +56,10 @@ def test_owner_pretrial_plan_data_when_trialing(self): """ % (current_org.username) data = self.gql_request(query, owner=current_org) assert data["owner"]["pretrialPlan"] == { - "marketingName": "Developer", + "marketingName": self.plan.marketing_name, "value": "users-basic", "billingRate": None, "baseUnitPrice": 0, - "benefits": [ - "Up to 234 users", - "Unlimited public repositories", - "Unlimited private repositories", - ], - "monthlyUploadLimit": 250, + "benefits": ["Benefit 1", "Benefit 2", "Benefit 3"], + "monthlyUploadLimit": None, } diff --git a/graphql_api/types/enums/tier_name.graphql b/graphql_api/types/enums/tier_name.graphql index 24e323be03..aeb825d0d4 100644 --- a/graphql_api/types/enums/tier_name.graphql +++ b/graphql_api/types/enums/tier_name.graphql @@ -3,4 +3,6 @@ enum TierName { TEAM PRO ENTERPRISE + SENTRY + TRIAL } diff --git a/graphql_api/types/owner/owner.py b/graphql_api/types/owner/owner.py index a268472156..4ce4665327 100644 --- a/graphql_api/types/owner/owner.py +++ b/graphql_api/types/owner/owner.py @@ -8,7 +8,7 @@ from ariadne import ObjectType from django.conf import settings from graphql import GraphQLResolveInfo -from shared.plan.constants import FREE_PLAN_REPRESENTATIONS, PlanData, PlanName +from shared.plan.constants import PlanData, PlanName, convert_to_DTO from shared.plan.service import PlanService import services.activation as activation @@ -22,6 +22,7 @@ Account, GithubAppInstallation, Owner, + Plan, ) from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY from core.models import Repository @@ -104,20 +105,25 @@ def resolve_yaml(owner: Owner, info: GraphQLResolveInfo) -> Optional[str]: @owner_bindable.field("plan") @require_part_of_org +@sync_to_async def resolve_plan(owner: Owner, info: GraphQLResolveInfo) -> PlanService: return PlanService(current_org=owner) @owner_bindable.field("pretrialPlan") @require_part_of_org +@sync_to_async def resolve_plan_representation(owner: Owner, info: GraphQLResolveInfo) -> PlanData: info.context["plan_service"] = PlanService(current_org=owner) - free_plan = FREE_PLAN_REPRESENTATIONS[PlanName.BASIC_PLAN_NAME.value] - return free_plan.convert_to_DTO() + free_plan = Plan.objects.select_related("tier").get( + name=PlanName.BASIC_PLAN_NAME.value + ) + return convert_to_DTO(free_plan) @owner_bindable.field("availablePlans") @require_part_of_org +@sync_to_async def resolve_available_plans(owner: Owner, info: GraphQLResolveInfo) -> List[PlanData]: plan_service = PlanService(current_org=owner) info.context["plan_service"] = plan_service diff --git a/graphql_api/types/plan/plan.py b/graphql_api/types/plan/plan.py index b10a84279e..3ad157a7e0 100644 --- a/graphql_api/types/plan/plan.py +++ b/graphql_api/types/plan/plan.py @@ -42,6 +42,7 @@ def resolve_marketing_name(plan_service: PlanService, info) -> str: @plan_bindable.field("value") +@sync_to_async def resolve_plan_name_as_value(plan_service: PlanService, info) -> str: return plan_service.plan_name @@ -71,6 +72,7 @@ def resolve_benefits(plan_service: PlanService, info) -> List[str]: @plan_bindable.field("pretrialUsersCount") +@sync_to_async def resolve_pretrial_users_count(plan_service: PlanService, info) -> Optional[int]: if plan_service.is_org_trialing: return plan_service.pretrial_users_count diff --git a/graphql_api/types/plan_representation/plan_representation.py b/graphql_api/types/plan_representation/plan_representation.py index 8ef856265a..800dc637b3 100644 --- a/graphql_api/types/plan_representation/plan_representation.py +++ b/graphql_api/types/plan_representation/plan_representation.py @@ -4,6 +4,7 @@ from shared.plan.constants import PlanData from shared.plan.service import PlanService +from codecov.db import sync_to_async from graphql_api.helpers.ariadne import ariadne_load_local_graphql plan_representation = ariadne_load_local_graphql( @@ -33,6 +34,7 @@ def resolve_base_unit_price(plan_data: PlanData, info) -> int: @plan_representation_bindable.field("benefits") +@sync_to_async def resolve_benefits(plan_data: PlanData, info) -> List[str]: plan_service: PlanService = info.context["plan_service"] if plan_service.is_org_trialing: diff --git a/requirements.in b/requirements.in index 5bea32f01f..fb46b3f820 100644 --- a/requirements.in +++ b/requirements.in @@ -26,7 +26,7 @@ freezegun google-cloud-pubsub gunicorn>=22.0.0 https://github.com/codecov/opentelem-python/archive/refs/tags/v0.0.4a1.tar.gz#egg=codecovopentelem -https://github.com/codecov/shared/archive/fe16480b3646a616ff412d5c0a28cafd2c7104c1.tar.gz#egg=shared +https://github.com/codecov/shared/archive/74c0888070699b69a4da73f54be502ad05fde7b6.tar.gz#egg=shared https://github.com/photocrowd/django-cursor-pagination/archive/f560902696b0c8509e4d95c10ba0d62700181d84.tar.gz idna>=3.7 minio diff --git a/requirements.txt b/requirements.txt index f88b5e98d5..503ae882cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -416,7 +416,7 @@ sentry-sdk[celery]==2.13.0 # shared setproctitle==1.1.10 # via -r requirements.in -shared @ https://github.com/codecov/shared/archive/fe16480b3646a616ff412d5c0a28cafd2c7104c1.tar.gz +shared @ https://github.com/codecov/shared/archive/74c0888070699b69a4da73f54be502ad05fde7b6.tar.gz # via -r requirements.in simplejson==3.17.2 # via -r requirements.in diff --git a/services/billing.py b/services/billing.py index 825cd7ccab..c33c05bcd2 100644 --- a/services/billing.py +++ b/services/billing.py @@ -7,16 +7,13 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from shared.plan.constants import ( - FREE_PLAN_REPRESENTATIONS, - PAID_PLANS, - TEAM_PLANS, - USER_PLAN_REPRESENTATIONS, PlanBillingRate, + TierName, ) from shared.plan.service import PlanService from billing.constants import REMOVED_INVOICE_STATUSES -from codecov_auth.models import Owner +from codecov_auth.models import Owner, Plan log = logging.getLogger(__name__) @@ -454,8 +451,8 @@ def _is_extending_term(self, owner: Owner, desired_plan: dict) -> bool: """ Returns `True` if switching from monthly to yearly plan. """ - current_plan_info = USER_PLAN_REPRESENTATIONS.get(owner.plan) - desired_plan_info = USER_PLAN_REPRESENTATIONS.get(desired_plan["value"]) + current_plan_info = Plan.objects.get(name=owner.plan) + desired_plan_info = Plan.objects.get(name=desired_plan["value"]) return bool( current_plan_info @@ -468,8 +465,10 @@ def _is_similar_plan(self, owner: Owner, desired_plan: dict) -> bool: """ Returns `True` if switching to a plan with similar term and seats. """ - current_plan_info = USER_PLAN_REPRESENTATIONS.get(owner.plan) - desired_plan_info = USER_PLAN_REPRESENTATIONS.get(desired_plan["value"]) + current_plan_info = Plan.objects.select_related("tier").get(name=owner.plan) + desired_plan_info = Plan.objects.select_related("tier").get( + name=desired_plan["value"] + ) is_same_term = ( current_plan_info @@ -480,12 +479,17 @@ def _is_similar_plan(self, owner: Owner, desired_plan: dict) -> bool: is_same_seats = ( owner.plan_user_count and owner.plan_user_count == desired_plan["quantity"] ) - # If from PRO to TEAM, then not a similar plan - if owner.plan not in TEAM_PLANS and desired_plan["value"] in TEAM_PLANS: + if ( + current_plan_info.tier.tier_name != TierName.TEAM.value + and desired_plan_info.tier.tier_name == TierName.TEAM.value + ): return False # If from TEAM to PRO, then considered a similar plan but really is an upgrade - elif owner.plan in TEAM_PLANS and desired_plan["value"] not in TEAM_PLANS: + elif ( + current_plan_info.tier.tier_name == TierName.TEAM.value + and desired_plan_info.tier.tier_name != TierName.TEAM.value + ): return True return bool(is_same_term and is_same_seats) @@ -794,22 +798,32 @@ def update_plan(self, owner, desired_plan): on current state, might create a stripe checkout session and return the checkout session's ID, which is a string. Otherwise returns None. """ - if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS: + try: + plan = Plan.objects.get(name=desired_plan["value"]) + except Plan.DoesNotExist: + log.warning( + f"Unable to find plan {desired_plan['value']} for owner {owner.ownerid}" + ) + return None + + if not plan.is_active: + log.warning( + f"Attempted to transition to non-existent or legacy plan: " + f"owner {owner.ownerid}, plan: {desired_plan}" + ) + return None + + if plan.paid_plan is False: if owner.stripe_subscription_id is not None: self.payment_service.delete_subscription(owner) else: plan_service = PlanService(current_org=owner) plan_service.set_default_plan_data() - elif desired_plan["value"] in PAID_PLANS: + else: if owner.stripe_subscription_id is not None: self.payment_service.modify_subscription(owner, desired_plan) else: return self.payment_service.create_checkout_session(owner, desired_plan) - else: - log.warning( - f"Attempted to transition to non-existent or legacy plan: " - f"owner {owner.ownerid}, plan: {desired_plan}" - ) def update_payment_method(self, owner, payment_method): """ diff --git a/services/tests/test_billing.py b/services/tests/test_billing.py index a2edd6640a..bbb1e8fdbe 100644 --- a/services/tests/test_billing.py +++ b/services/tests/test_billing.py @@ -9,6 +9,7 @@ from shared.plan.constants import PlanName from stripe import InvalidRequestError +from billing.helpers import mock_all_plans_and_tiers from codecov_auth.models import Service from services.billing import AbstractPaymentService, BillingService, StripeService @@ -176,6 +177,11 @@ def __getitem__(self, key): class StripeServiceTests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + def setUp(self): self.user = OwnerFactory() self.stripe = StripeService(requesting_user=self.user) @@ -1872,6 +1878,11 @@ def create_setup_intent(self, owner): class BillingServiceTests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + def setUp(self): self.mock_payment_service = MockPaymentService() self.billing_service = BillingService(payment_service=self.mock_payment_service) diff --git a/upload/helpers.py b/upload/helpers.py index bccd92e034..9e6cae583a 100644 --- a/upload/helpers.py +++ b/upload/helpers.py @@ -15,7 +15,6 @@ from redis import Redis from rest_framework.exceptions import NotFound, Throttled, ValidationError from shared.github import InvalidInstallationError -from shared.plan.constants import USER_PLAN_REPRESENTATIONS from shared.plan.service import PlanService from shared.reports.enums import UploadType from shared.torngit.base import TorngitBaseAdapter @@ -29,6 +28,7 @@ SERVICE_GITHUB_ENTERPRISE, GithubAppInstallation, Owner, + Plan, ) from core.models import Commit, Repository from reports.models import CommitReport, ReportSession @@ -637,7 +637,10 @@ def validate_upload( owner = _determine_responsible_owner(repository) # If author is on per repo billing, check their repo credits - if owner.plan not in USER_PLAN_REPRESENTATIONS and owner.repo_credits <= 0: + if ( + owner.plan not in Plan.objects.values_list("name", flat=True) + and owner.repo_credits <= 0 + ): raise ValidationError( "Sorry, but this team has no private repository credits left." ) diff --git a/upload/tests/test_helpers.py b/upload/tests/test_helpers.py index e284d3d2ee..0f2e8c1b75 100644 --- a/upload/tests/test_helpers.py +++ b/upload/tests/test_helpers.py @@ -4,7 +4,7 @@ import jwt import pytest from django.conf import settings -from django.test import TransactionTestCase +from django.test import TestCase from rest_framework.exceptions import Throttled, ValidationError from shared.django_apps.core.tests.factories import ( CommitFactory, @@ -15,6 +15,7 @@ from shared.plan.constants import PlanName from shared.upload.utils import UploaderType, insert_coverage_measurement +from billing.helpers import mock_all_plans_and_tiers from codecov_auth.models import GithubAppInstallation, Service from reports.tests.factories import CommitReportFactory, UploadFactory from upload.helpers import ( @@ -27,7 +28,7 @@ ) -class TestGithubAppInstallationUsage(TransactionTestCase): +class TestGithubAppInstallationUsage(TestCase): def test_not_github_provider(self): repo = RepositoryFactory(author__service=Service.GITLAB.value) assert ghapp_installation_id_to_use(repo) is None @@ -169,6 +170,7 @@ def test_check_commit_constraints_settings_disabled(db, settings): def test_check_commit_constraints_settings_enabled(db, settings, mocker): settings.UPLOAD_THROTTLING_ENABLED = True + mock_all_plans_and_tiers() author = OwnerFactory.create(plan=PlanName.BASIC_PLAN_NAME.value) repository = RepositoryFactory.create(author=author, private=True) public_repository = RepositoryFactory.create(author=author, private=False) diff --git a/upload/tests/test_throttles.py b/upload/tests/test_throttles.py index 51c99471a0..5dbeac5c63 100644 --- a/upload/tests/test_throttles.py +++ b/upload/tests/test_throttles.py @@ -11,14 +11,18 @@ from shared.plan.constants import PlanName from shared.upload.utils import UploaderType, insert_coverage_measurement +from billing.helpers import mock_all_plans_and_tiers from reports.tests.factories import CommitReportFactory, UploadFactory from services.redis_configuration import get_redis_connection from upload.throttles import UploadsPerCommitThrottle, UploadsPerWindowThrottle class ThrottlesUnitTests(APITestCase): - def setUp(self): - self.owner = OwnerFactory( + @classmethod + def setUpClass(cls): + super().setUpClass() + mock_all_plans_and_tiers() + cls.owner = OwnerFactory( plan=PlanName.BASIC_PLAN_NAME.value, max_upload_limit=150 ) diff --git a/upload/tests/views/test_uploads.py b/upload/tests/views/test_uploads.py index ec88613991..e55ea2576d 100644 --- a/upload/tests/views/test_uploads.py +++ b/upload/tests/views/test_uploads.py @@ -7,13 +7,16 @@ from rest_framework.exceptions import ValidationError from rest_framework.test import APIClient, APITestCase from shared.api_archive.archive import ArchiveService, MinioEndpoints +from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory from shared.django_apps.core.tests.factories import ( CommitFactory, OwnerFactory, RepositoryFactory, ) +from shared.plan.constants import PlanName, TierName from shared.utils.test_utils import mock_config_helper +from billing.helpers import mock_all_plans_and_tiers from codecov_auth.authentication.repo_auth import OrgLevelTokenRepositoryAuth from codecov_auth.services.org_level_token_service import OrgLevelTokenService from reports.models import ( @@ -40,7 +43,9 @@ def test_upload_permission_class_pass(db, mocker): def test_upload_permission_orglevel_token(db, mocker): - owner = OwnerFactory(plan="users-enterprisem") + tier = TierFactory(tier_name=TierName.ENTERPRISE.value) + plan = PlanFactory(name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value, tier=tier) + owner = OwnerFactory(plan=plan.name) owner.save() repo = RepositoryFactory(author=owner) repo.save() @@ -63,7 +68,9 @@ def test_upload_permission_class_fail(db, mocker): def test_upload_permission_orglevel_fail(db, mocker): - owner = OwnerFactory(plan="users-enterprisem") + tier = TierFactory(tier_name=TierName.ENTERPRISE.value) + plan = PlanFactory(name=PlanName.ENTERPRISE_CLOUD_MONTHLY.value, tier=tier) + owner = OwnerFactory(plan=plan.name) owner.save() repo = RepositoryFactory() # Not the same owner of the token repo.save() @@ -78,6 +85,7 @@ def test_upload_permission_orglevel_fail(db, mocker): def test_uploads_get_not_allowed(client, db, mocker): + mock_all_plans_and_tiers() mocker.patch.object( CanDoCoverageUploadsPermission, "has_permission", return_value=True ) @@ -193,6 +201,7 @@ def test_get_report_error(db): def test_uploads_post(db, mocker, mock_redis): + mock_all_plans_and_tiers() # TODO remove the mock object and test the flow with the permissions mocker.patch.object( CanDoCoverageUploadsPermission, "has_permission", return_value=True @@ -712,6 +721,7 @@ def test_uploads_post_github_oidc_auth( @override_settings(SHELTER_SHARED_SECRET="shelter-shared-secret") def test_uploads_post_shelter(db, mocker, mock_redis): + mock_all_plans_and_tiers() mocker.patch.object( CanDoCoverageUploadsPermission, "has_permission", return_value=True ) @@ -781,6 +791,7 @@ def test_uploads_post_shelter(db, mocker, mock_redis): def test_deactivated_repo(db, mocker, mock_redis): + mock_all_plans_and_tiers() mocker.patch.object( CanDoCoverageUploadsPermission, "has_permission", return_value=True )