Skip to content

Commit

Permalink
move create-setup-intent to graphql api
Browse files Browse the repository at this point in the history
  • Loading branch information
suejung-sentry committed Jan 14, 2025
1 parent 0d80364 commit 198f23e
Show file tree
Hide file tree
Showing 17 changed files with 207 additions and 53 deletions.
3 changes: 0 additions & 3 deletions api/internal/owner/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,8 @@ class StripeCardSerializer(serializers.Serializer):


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


class StripePaymentMethodSerializer(serializers.Serializer):
Expand Down
36 changes: 30 additions & 6 deletions api/internal/owner/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,31 @@ 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
- should_propagate_to_payment_methods: Optional boolean flag to update email on payment methods (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)
should_propagate = request.data.get("should_propagate_to_payment_methods", False)
billing.update_email_address(owner, new_email, should_propagate_to_payment_methods=should_propagate)
should_propagate = request.data.get(
"should_propagate_to_payment_methods", False
)
billing.update_email_address(
owner, new_email, should_propagate_to_payment_methods=should_propagate
)
return Response(self.get_serializer(owner).data)

@action(detail=False, methods=["patch"])
Expand All @@ -113,16 +131,21 @@ def update_billing_address(self, request, *args, **kwargs):
billing.update_billing_address(owner, name, billing_address=formatted_address)
return Response(self.get_serializer(owner).data)


@action(detail=False, methods=["get"])
@action(detail=False, methods=["post"])
@stripe_safe
def setup_intent(self, request, *args, **kwargs):
"""
GET a Stripe setupIntent clientSecret for updating payment method
Create a Stripe SetupIntent to securely collect payment details.
Returns:
Response with SetupIntent client_secret for frontend payment method setup.
Raises:
ValidationError: If SetupIntent creation fails
"""
try:
billing = BillingService(requesting_user=request.current_owner)
client_secret = billing.get_setup_intent(self.owner)
client_secret = billing.create_setup_intent(self.owner)
return Response({"client_secret": client_secret})
except Exception as e:
log.error(

Check warning on line 151 in api/internal/owner/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

api/internal/owner/views.py#L146-L151

Added lines #L146 - L151 were not covered by tests
Expand All @@ -131,6 +154,7 @@ def setup_intent(self, request, *args, **kwargs):
)
raise ValidationError(detail="Unable to create setup intent")

Check warning on line 155 in api/internal/owner/views.py

View check run for this annotation

Codecov Notifications / codecov/patch

api/internal/owner/views.py#L155

Added line #L155 was not covered by tests


class UsersOrderingFilter(filters.OrderingFilter):
def get_valid_fields(self, queryset, view, context=None):
fields = super().get_valid_fields(queryset, view, context=context or {})
Expand Down
3 changes: 0 additions & 3 deletions billing/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,6 @@ def customer_subscription_created(self, subscription: stripe.Subscription) -> No
self._log_updated([owner])

def customer_subscription_updated(self, subscription: stripe.Subscription) -> None:
print("CUSTOMER SUBSCRIPTION UPDATED", subscription)
owners: QuerySet[Owner] = Owner.objects.filter(
stripe_subscription_id=subscription.id,
stripe_customer_id=subscription.customer,
Expand Down Expand Up @@ -409,8 +408,6 @@ def customer_subscription_updated(self, subscription: stripe.Subscription) -> No
)

def customer_updated(self, customer: stripe.Customer) -> None:
print("CUSTOMER UPDATED", customer)

new_default_payment_method = customer["invoice_settings"][
"default_payment_method"
]
Expand Down
8 changes: 6 additions & 2 deletions codecov/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@
"setup", "graphql", "query_cost_threshold", default=10000
)

GRAPHQL_RATE_LIMIT_ENABLED = get_config("setup", "graphql", "rate_limit_enabled", default=True)
GRAPHQL_RATE_LIMIT_ENABLED = get_config(
"setup", "graphql", "rate_limit_enabled", default=True
)

GRAPHQL_RATE_LIMIT_RPM = get_config("setup", "graphql", "rate_limit_rpm", default=300)

Expand Down Expand Up @@ -426,7 +428,9 @@
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", default=None)
STRIPE_PAYMENT_METHOD_CONFIGURATION_ID = get_config(
"setup", "stripe", "payment_method_configuration", default=None
)

# Allows to do migrations from another module
MIGRATION_MODULES = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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")

Check warning on line 19 in codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py

View check run for this annotation

Codecov Notifications / codecov/patch

codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py#L19

Added line #L19 was not covered by tests
if not current_user_part_of_org(self.current_owner, owner_obj):
raise Unauthorized()

Check warning on line 21 in codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py

View check run for this annotation

Codecov Notifications / codecov/patch

codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py#L21

Added line #L21 was not covered by tests

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(

Check warning on line 28 in codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py

View check run for this annotation

Codecov Notifications / codecov/patch

codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py#L27-L28

Added lines #L27 - L28 were not covered by tests
f"Error getting setup intent for owner {owner_obj.ownerid}",
extra={"error": str(e)},
)
raise ValidationError("Unable to create setup intent")

Check warning on line 32 in codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py

View check run for this annotation

Codecov Notifications / codecov/patch

codecov_auth/commands/owner/interactors/create_stripe_setup_intent.py#L32

Added line #L32 was not covered by tests

@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
34 changes: 34 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,34 @@
from unittest.mock import patch
from django.test import TransactionTestCase
from shared.django_apps.core.tests.factories import OwnerFactory

from codecov_auth.models import Session
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"

@patch("services.billing.stripe.SetupIntent.create")
def test_when_authenticated(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: 2 additions & 4 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,12 +44,9 @@ type Card {
last4: String
}

type BankAccount {
accountHolderType: String
accountType: String
type USBankAccount {
bankName: String
last4: String
routingNumber: String
}

type BillingDetails {
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 @@ -30,6 +31,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
12 changes: 12 additions & 0 deletions graphql_api/types/mutation/create_stripe_setup_intent/__init__.py
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 | ValidationError

type CreateStripeSetupIntentPayload {
error: CreateStripeSetupIntentError
clientSecret: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import 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(
_, info: GraphQLResolveInfo, input: Dict[str, any]
) -> Dict[str, any]:
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 @@ -64,6 +68,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 @@ -103,6 +108,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

0 comments on commit 198f23e

Please sign in to comment.