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

Commit 4c5ef9d

Browse files
Merge branch 'main' into rvinnakota/add-ai-auth-endpoint
2 parents d52a405 + ab5c1ff commit 4c5ef9d

File tree

22 files changed

+613
-14
lines changed

22 files changed

+613
-14
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
25.1.10
1+
25.1.16

api/internal/owner/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,14 @@ class StripeCardSerializer(serializers.Serializer):
109109
last4 = serializers.CharField()
110110

111111

112+
class StripeUSBankAccountSerializer(serializers.Serializer):
113+
bank_name = serializers.CharField()
114+
last4 = serializers.CharField()
115+
116+
112117
class StripePaymentMethodSerializer(serializers.Serializer):
113118
card = StripeCardSerializer(read_only=True)
119+
us_bank_account = StripeUSBankAccountSerializer(read_only=True)
114120
billing_details = serializers.JSONField(read_only=True)
115121

116122

api/internal/owner/views.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,33 @@ def update_payment(self, request, *args, **kwargs):
8080
@action(detail=False, methods=["patch"])
8181
@stripe_safe
8282
def update_email(self, request, *args, **kwargs):
83+
"""
84+
Update the email address associated with the owner's billing account.
85+
86+
Args:
87+
request: The HTTP request object containing:
88+
- new_email: The new email address to update to
89+
- apply_to_default_payment_method: Boolean flag to update email on the default payment method (default False)
90+
91+
Returns:
92+
Response with serialized owner data
93+
94+
Raises:
95+
ValidationError: If no new_email is provided in the request
96+
"""
8397
new_email = request.data.get("new_email")
8498
if not new_email:
8599
raise ValidationError(detail="No new_email sent")
86100
owner = self.get_object()
87101
billing = BillingService(requesting_user=request.current_owner)
88-
billing.update_email_address(owner, new_email)
102+
apply_to_default_payment_method = request.data.get(
103+
"apply_to_default_payment_method", False
104+
)
105+
billing.update_email_address(
106+
owner,
107+
new_email,
108+
apply_to_default_payment_method=apply_to_default_payment_method,
109+
)
89110
return Response(self.get_serializer(owner).data)
90111

91112
@action(detail=False, methods=["patch"])

api/internal/tests/views/test_account_viewset.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,46 @@ def test_update_email_address(self, modify_customer_mock, retrieve_mock):
12281228
self.current_owner.stripe_customer_id, email=new_email
12291229
)
12301230

1231+
@patch("services.billing.stripe.Subscription.retrieve")
1232+
@patch("services.billing.stripe.Customer.modify")
1233+
@patch("services.billing.stripe.PaymentMethod.modify")
1234+
@patch("services.billing.stripe.Customer.retrieve")
1235+
def test_update_email_address_with_propagate(
1236+
self,
1237+
customer_retrieve_mock,
1238+
payment_method_mock,
1239+
modify_customer_mock,
1240+
retrieve_mock,
1241+
):
1242+
self.current_owner.stripe_customer_id = "flsoe"
1243+
self.current_owner.stripe_subscription_id = "djfos"
1244+
self.current_owner.save()
1245+
1246+
payment_method_id = "pm_123"
1247+
customer_retrieve_mock.return_value = {
1248+
"invoice_settings": {"default_payment_method": payment_method_id}
1249+
}
1250+
1251+
new_email = "[email protected]"
1252+
kwargs = {
1253+
"service": self.current_owner.service,
1254+
"owner_username": self.current_owner.username,
1255+
}
1256+
data = {"new_email": new_email, "apply_to_default_payment_method": True}
1257+
url = reverse("account_details-update-email", kwargs=kwargs)
1258+
response = self.client.patch(url, data=data, format="json")
1259+
assert response.status_code == status.HTTP_200_OK
1260+
1261+
modify_customer_mock.assert_called_once_with(
1262+
self.current_owner.stripe_customer_id, email=new_email
1263+
)
1264+
customer_retrieve_mock.assert_called_once_with(
1265+
self.current_owner.stripe_customer_id
1266+
)
1267+
payment_method_mock.assert_called_once_with(
1268+
payment_method_id, billing_details={"email": new_email}
1269+
)
1270+
12311271
def test_update_billing_address_without_body(self):
12321272
kwargs = {
12331273
"service": self.current_owner.service,

codecov/settings_base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@
429429
SHELTER_PUBSUB_PROJECT_ID = get_config("setup", "shelter", "pubsub_project_id")
430430
SHELTER_PUBSUB_SYNC_REPO_TOPIC_ID = get_config("setup", "shelter", "sync_repo_topic_id")
431431

432+
STRIPE_PAYMENT_METHOD_CONFIGURATION_ID = get_config(
433+
"setup", "stripe", "payment_method_configuration_id", default=None
434+
)
435+
432436
# Allows to do migrations from another module
433437
MIGRATION_MODULES = {
434438
"codecov_auth": "shared.django_apps.codecov_auth.migrations",

codecov_auth/admin.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.contrib.admin.models import LogEntry
99
from django.db.models import OuterRef, Subquery
1010
from django.db.models.fields import BLANK_CHOICE_DASH
11-
from django.forms import CheckboxInput, Select
11+
from django.forms import CheckboxInput, Select, Textarea
1212
from django.http import HttpRequest
1313
from django.shortcuts import redirect, render
1414
from django.utils import timezone
@@ -17,7 +17,9 @@
1717
Account,
1818
AccountsUsers,
1919
InvoiceBilling,
20+
Plan,
2021
StripeBilling,
22+
Tier,
2123
)
2224
from shared.plan.constants import USER_PLAN_REPRESENTATIONS
2325
from shared.plan.service import PlanService
@@ -708,3 +710,112 @@ class AccountsUsersAdmin(AdminMixin, admin.ModelAdmin):
708710
]
709711

710712
fields = readonly_fields + ["account", "user"]
713+
714+
715+
class PlansInline(admin.TabularInline):
716+
model = Plan
717+
extra = 1
718+
verbose_name_plural = "Plans (click save to commit changes)"
719+
verbose_name = "Plan"
720+
fields = [
721+
"name",
722+
"marketing_name",
723+
"base_unit_price",
724+
"billing_rate",
725+
"max_seats",
726+
"monthly_uploads_limit",
727+
"paid_plan",
728+
"is_active",
729+
"stripe_id",
730+
]
731+
formfield_overrides = {
732+
Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
733+
}
734+
735+
736+
@admin.register(Tier)
737+
class TierAdmin(admin.ModelAdmin):
738+
list_display = (
739+
"tier_name",
740+
"bundle_analysis",
741+
"test_analytics",
742+
"flaky_test_detection",
743+
"project_coverage",
744+
"private_repo_support",
745+
)
746+
list_editable = (
747+
"bundle_analysis",
748+
"test_analytics",
749+
"flaky_test_detection",
750+
"project_coverage",
751+
"private_repo_support",
752+
)
753+
search_fields = ("tier_name__iregex",)
754+
inlines = [PlansInline]
755+
fields = [
756+
"tier_name",
757+
"bundle_analysis",
758+
"test_analytics",
759+
"flaky_test_detection",
760+
"project_coverage",
761+
"private_repo_support",
762+
]
763+
764+
765+
class PlanAdminForm(forms.ModelForm):
766+
class Meta:
767+
model = Plan
768+
fields = "__all__"
769+
770+
def clean_base_unit_price(self) -> int | None:
771+
base_unit_price = self.cleaned_data.get("base_unit_price")
772+
if base_unit_price is not None and base_unit_price < 0:
773+
raise forms.ValidationError("Base unit price cannot be negative.")
774+
return base_unit_price
775+
776+
def clean_max_seats(self) -> int | None:
777+
max_seats = self.cleaned_data.get("max_seats")
778+
if max_seats is not None and max_seats < 0:
779+
raise forms.ValidationError("Max seats cannot be negative.")
780+
return max_seats
781+
782+
def clean_monthly_uploads_limit(self) -> int | None:
783+
monthly_uploads_limit = self.cleaned_data.get("monthly_uploads_limit")
784+
if monthly_uploads_limit is not None and monthly_uploads_limit < 0:
785+
raise forms.ValidationError("Monthly uploads limit cannot be negative.")
786+
return monthly_uploads_limit
787+
788+
789+
@admin.register(Plan)
790+
class PlanAdmin(admin.ModelAdmin):
791+
form = PlanAdminForm
792+
list_display = (
793+
"name",
794+
"marketing_name",
795+
"is_active",
796+
"tier",
797+
"paid_plan",
798+
"billing_rate",
799+
"base_unit_price",
800+
"max_seats",
801+
"monthly_uploads_limit",
802+
)
803+
list_filter = ("is_active", "paid_plan", "billing_rate", "tier")
804+
search_fields = ("name__iregex", "marketing_name__iregex")
805+
fields = [
806+
"tier",
807+
"name",
808+
"marketing_name",
809+
"base_unit_price",
810+
"benefits",
811+
"billing_rate",
812+
"is_active",
813+
"max_seats",
814+
"monthly_uploads_limit",
815+
"paid_plan",
816+
"stripe_id",
817+
]
818+
formfield_overrides = {
819+
Plan._meta.get_field("benefits"): {"widget": Textarea(attrs={"rows": 3})},
820+
}
821+
autocomplete_fields = ["tier"] # a dropdown for selecting related Tiers
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import logging
2+
3+
import stripe
4+
5+
from codecov.commands.base import BaseInteractor
6+
from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
7+
from codecov.db import sync_to_async
8+
from codecov_auth.helpers import current_user_part_of_org
9+
from codecov_auth.models import Owner
10+
from services.billing import BillingService
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
class CreateStripeSetupIntentInteractor(BaseInteractor):
16+
def validate(self, owner_obj: Owner) -> None:
17+
if not self.current_user.is_authenticated:
18+
raise Unauthenticated()
19+
if not owner_obj:
20+
raise ValidationError("Owner not found")
21+
if not current_user_part_of_org(self.current_owner, owner_obj):
22+
raise Unauthorized()
23+
24+
def create_setup_intent(self, owner_obj: Owner) -> stripe.SetupIntent:
25+
try:
26+
billing = BillingService(requesting_user=self.current_owner)
27+
return billing.create_setup_intent(owner_obj)
28+
except Exception as e:
29+
log.error(
30+
"Error getting setup intent",
31+
extra={
32+
"ownerid": owner_obj.ownerid,
33+
"error": str(e),
34+
},
35+
)
36+
raise ValidationError("Unable to create setup intent")
37+
38+
@sync_to_async
39+
def execute(self, owner: str) -> stripe.SetupIntent:
40+
owner_obj = Owner.objects.filter(username=owner, service=self.service).first()
41+
self.validate(owner_obj)
42+
return self.create_setup_intent(owner_obj)

codecov_auth/commands/owner/owner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .interactors.cancel_trial import CancelTrialInteractor
44
from .interactors.create_api_token import CreateApiTokenInteractor
5+
from .interactors.create_stripe_setup_intent import CreateStripeSetupIntentInteractor
56
from .interactors.create_user_token import CreateUserTokenInteractor
67
from .interactors.delete_session import DeleteSessionInteractor
78
from .interactors.fetch_owner import FetchOwnerInteractor
@@ -28,6 +29,9 @@ class OwnerCommands(BaseCommand):
2829
def create_api_token(self, name):
2930
return self.get_interactor(CreateApiTokenInteractor).execute(name)
3031

32+
def create_stripe_setup_intent(self, owner):
33+
return self.get_interactor(CreateStripeSetupIntentInteractor).execute(owner)
34+
3135
def delete_session(self, sessionid: int):
3236
return self.get_interactor(DeleteSessionInteractor).execute(sessionid)
3337

0 commit comments

Comments
 (0)