Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.
6 changes: 6 additions & 0 deletions api/internal/owner/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ class StripeCardSerializer(serializers.Serializer):
last4 = serializers.CharField()


class StripeUSBankAccountSerializer(serializers.Serializer):
bank_name = serializers.CharField()
last4 = serializers.CharField()


class StripePaymentMethodSerializer(serializers.Serializer):
card = StripeCardSerializer(read_only=True)
us_bank_account = StripeUSBankAccountSerializer(read_only=True)
billing_details = serializers.JSONField(read_only=True)


Expand Down
23 changes: 22 additions & 1 deletion api/internal/owner/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,33 @@ def update_payment(self, request, *args, **kwargs):
@action(detail=False, methods=["patch"])
@stripe_safe
def update_email(self, request, *args, **kwargs):
"""
Update the email address associated with the owner's billing account.

Args:
request: The HTTP request object containing:
- new_email: The new email address to update to
- apply_to_default_payment_method: Boolean flag to update email on the default payment method (default False)

Returns:
Response with serialized owner data

Raises:
ValidationError: If no new_email is provided in the request
"""
new_email = request.data.get("new_email")
if not new_email:
raise ValidationError(detail="No new_email sent")
owner = self.get_object()
billing = BillingService(requesting_user=request.current_owner)
billing.update_email_address(owner, new_email)
apply_to_default_payment_method = request.data.get(
"apply_to_default_payment_method", False
)
billing.update_email_address(
owner,
new_email,
apply_to_default_payment_method=apply_to_default_payment_method,
)
return Response(self.get_serializer(owner).data)

@action(detail=False, methods=["patch"])
Expand Down
40 changes: 40 additions & 0 deletions api/internal/tests/views/test_account_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,46 @@ def test_update_email_address(self, modify_customer_mock, retrieve_mock):
self.current_owner.stripe_customer_id, email=new_email
)

@patch("services.billing.stripe.Subscription.retrieve")
@patch("services.billing.stripe.Customer.modify")
@patch("services.billing.stripe.PaymentMethod.modify")
@patch("services.billing.stripe.Customer.retrieve")
def test_update_email_address_with_propagate(
self,
customer_retrieve_mock,
payment_method_mock,
modify_customer_mock,
retrieve_mock,
):
self.current_owner.stripe_customer_id = "flsoe"
self.current_owner.stripe_subscription_id = "djfos"
self.current_owner.save()

payment_method_id = "pm_123"
customer_retrieve_mock.return_value = {
"invoice_settings": {"default_payment_method": payment_method_id}
}

new_email = "[email protected]"
kwargs = {
"service": self.current_owner.service,
"owner_username": self.current_owner.username,
}
data = {"new_email": new_email, "apply_to_default_payment_method": True}
url = reverse("account_details-update-email", kwargs=kwargs)
response = self.client.patch(url, data=data, format="json")
assert response.status_code == status.HTTP_200_OK

modify_customer_mock.assert_called_once_with(
self.current_owner.stripe_customer_id, email=new_email
)
customer_retrieve_mock.assert_called_once_with(
self.current_owner.stripe_customer_id
)
payment_method_mock.assert_called_once_with(
payment_method_id, billing_details={"email": new_email}
)

def test_update_billing_address_without_body(self):
kwargs = {
"service": self.current_owner.service,
Expand Down
4 changes: 4 additions & 0 deletions codecov/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,10 @@
SHELTER_PUBSUB_PROJECT_ID = get_config("setup", "shelter", "pubsub_project_id")
SHELTER_PUBSUB_SYNC_REPO_TOPIC_ID = get_config("setup", "shelter", "sync_repo_topic_id")

STRIPE_PAYMENT_METHOD_CONFIGURATION_ID = get_config(
"setup", "stripe", "payment_method_configuration_id", default=None
)

# Allows to do migrations from another module
MIGRATION_MODULES = {
"codecov_auth": "shared.django_apps.codecov_auth.migrations",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging

import stripe

from codecov.commands.base import BaseInteractor
from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError
from codecov.db import sync_to_async
from codecov_auth.helpers import current_user_part_of_org
from codecov_auth.models import Owner
from services.billing import BillingService

log = logging.getLogger(__name__)


class CreateStripeSetupIntentInteractor(BaseInteractor):
def validate(self, owner_obj: Owner) -> None:
if not self.current_user.is_authenticated:
raise Unauthenticated()
if not owner_obj:
raise ValidationError("Owner not found")
if not current_user_part_of_org(self.current_owner, owner_obj):
raise Unauthorized()

def create_setup_intent(self, owner_obj: Owner) -> stripe.SetupIntent:
try:
billing = BillingService(requesting_user=self.current_owner)
return billing.create_setup_intent(owner_obj)
except Exception as e:
log.error(
"Error getting setup intent",
extra={
"ownerid": owner_obj.ownerid,
"error": str(e),
},
)
raise ValidationError("Unable to create setup intent")

@sync_to_async
def execute(self, owner: str) -> stripe.SetupIntent:
owner_obj = Owner.objects.filter(username=owner, service=self.service).first()
self.validate(owner_obj)
return self.create_setup_intent(owner_obj)
4 changes: 4 additions & 0 deletions codecov_auth/commands/owner/owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .interactors.cancel_trial import CancelTrialInteractor
from .interactors.create_api_token import CreateApiTokenInteractor
from .interactors.create_stripe_setup_intent import CreateStripeSetupIntentInteractor
from .interactors.create_user_token import CreateUserTokenInteractor
from .interactors.delete_session import DeleteSessionInteractor
from .interactors.fetch_owner import FetchOwnerInteractor
Expand All @@ -28,6 +29,9 @@ class OwnerCommands(BaseCommand):
def create_api_token(self, name):
return self.get_interactor(CreateApiTokenInteractor).execute(name)

def create_stripe_setup_intent(self, owner):
return self.get_interactor(CreateStripeSetupIntentInteractor).execute(owner)

def delete_session(self, sessionid: int):
return self.get_interactor(DeleteSessionInteractor).execute(sessionid)

Expand Down
67 changes: 67 additions & 0 deletions graphql_api/tests/mutation/test_create_stripe_setup_intent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from unittest.mock import patch

from django.test import TransactionTestCase
from shared.django_apps.core.tests.factories import OwnerFactory

from graphql_api.tests.helper import GraphQLTestHelper

query = """
mutation($input: CreateStripeSetupIntentInput!) {
createStripeSetupIntent(input: $input) {
error {
__typename
}
clientSecret
}
}
"""


class CreateStripeSetupIntentTestCase(GraphQLTestHelper, TransactionTestCase):
def setUp(self):
self.owner = OwnerFactory(username="codecov-user")

def test_when_unauthenticated(self):
data = self.gql_request(query, variables={"input": {"owner": "somename"}})
assert (
data["createStripeSetupIntent"]["error"]["__typename"]
== "UnauthenticatedError"
)

def test_when_unauthorized(self):
other_owner = OwnerFactory(username="other-user")
data = self.gql_request(
query,
owner=self.owner,
variables={"input": {"owner": other_owner.username}},
)
assert (
data["createStripeSetupIntent"]["error"]["__typename"]
== "UnauthorizedError"
)

@patch("services.billing.stripe.SetupIntent.create")
def test_when_validation_error(self, setup_intent_create_mock):
setup_intent_create_mock.side_effect = Exception("Some error")
data = self.gql_request(
query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
)
assert (
data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
)

def test_when_owner_not_found(self):
data = self.gql_request(
query, owner=self.owner, variables={"input": {"owner": "nonexistent-user"}}
)
assert (
data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
)

@patch("services.billing.stripe.SetupIntent.create")
def test_success(self, setup_intent_create_mock):
setup_intent_create_mock.return_value = {"client_secret": "test-client-secret"}
data = self.gql_request(
query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
)
assert data["createStripeSetupIntent"]["clientSecret"] == "test-client-secret"
3 changes: 3 additions & 0 deletions graphql_api/types/inputs/create_stripe_setup_intent.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
input CreateStripeSetupIntentInput {
owner: String!
}
6 changes: 6 additions & 0 deletions graphql_api/types/invoice/invoice.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Period {
type PaymentMethod {
billingDetails: BillingDetails
card: Card
usBankAccount: USBankAccount
}

type Card {
Expand All @@ -43,6 +44,11 @@ type Card {
last4: String
}

type USBankAccount {
bankName: String
last4: String
}

type BillingDetails {
address: Address
email: String
Expand Down
2 changes: 2 additions & 0 deletions graphql_api/types/mutation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .activate_measurements import gql_activate_measurements
from .cancel_trial import gql_cancel_trial
from .create_api_token import gql_create_api_token
from .create_stripe_setup_intent import gql_create_stripe_setup_intent
from .create_user_token import gql_create_user_token
from .delete_component_measurements import gql_delete_component_measurements
from .delete_flag import gql_delete_flag
Expand Down Expand Up @@ -31,6 +32,7 @@

mutation = ariadne_load_local_graphql(__file__, "mutation.graphql")
mutation = mutation + gql_create_api_token
mutation = mutation + gql_create_stripe_setup_intent
mutation = mutation + gql_sync_with_git_provider
mutation = mutation + gql_delete_session
mutation = mutation + gql_set_yaml_on_owner
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from graphql_api.helpers.ariadne import ariadne_load_local_graphql

from .create_stripe_setup_intent import (
error_create_stripe_setup_intent,
resolve_create_stripe_setup_intent,
)

gql_create_stripe_setup_intent = ariadne_load_local_graphql(
__file__, "create_stripe_setup_intent.graphql"
)

__all__ = ["error_create_stripe_setup_intent", "resolve_create_stripe_setup_intent"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
union CreateStripeSetupIntentError = UnauthenticatedError | UnauthorizedError | ValidationError

type CreateStripeSetupIntentPayload {
error: CreateStripeSetupIntentError
clientSecret: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Any, Dict

from ariadne import UnionType
from ariadne.types import GraphQLResolveInfo

from graphql_api.helpers.mutation import (
resolve_union_error_type,
wrap_error_handling_mutation,
)


@wrap_error_handling_mutation
async def resolve_create_stripe_setup_intent(
_: Any, info: GraphQLResolveInfo, input: Dict[str, str]
) -> Dict[str, str]:
command = info.context["executor"].get_command("owner")
resp = await command.create_stripe_setup_intent(input.get("owner"))
return {
"client_secret": resp["client_secret"],
}


error_create_stripe_setup_intent = UnionType("CreateStripeSetupIntentError")
error_create_stripe_setup_intent.type_resolver(resolve_union_error_type)
1 change: 1 addition & 0 deletions graphql_api/types/mutation/mutation.graphql
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type Mutation {
createApiToken(input: CreateApiTokenInput!): CreateApiTokenPayload
createStripeSetupIntent(input: CreateStripeSetupIntentInput!): CreateStripeSetupIntentPayload
createUserToken(input: CreateUserTokenInput!): CreateUserTokenPayload
revokeUserToken(input: RevokeUserTokenInput!): RevokeUserTokenPayload
setYamlOnOwner(input: SetYamlOnOwnerInput!): SetYamlOnOwnerPayload
Expand Down
6 changes: 6 additions & 0 deletions graphql_api/types/mutation/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
)
from .cancel_trial import error_cancel_trial, resolve_cancel_trial
from .create_api_token import error_create_api_token, resolve_create_api_token
from .create_stripe_setup_intent import (
error_create_stripe_setup_intent,
resolve_create_stripe_setup_intent,
)
from .create_user_token import error_create_user_token, resolve_create_user_token
from .delete_component_measurements import (
error_delete_component_measurements,
Expand Down Expand Up @@ -68,6 +72,7 @@

# Here, bind the resolvers from each subfolder to the Mutation type
mutation_bindable.field("createApiToken")(resolve_create_api_token)
mutation_bindable.field("createStripeSetupIntent")(resolve_create_stripe_setup_intent)
mutation_bindable.field("createUserToken")(resolve_create_user_token)
mutation_bindable.field("revokeUserToken")(resolve_revoke_user_token)
mutation_bindable.field("setYamlOnOwner")(resolve_set_yaml_on_owner)
Expand Down Expand Up @@ -108,6 +113,7 @@
mutation_resolvers = [
mutation_bindable,
error_create_api_token,
error_create_stripe_setup_intent,
error_create_user_token,
error_revoke_user_token,
error_set_yaml_error,
Expand Down
Loading
Loading