Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ACH payment method #1083

Merged
merged 10 commits into from
Jan 16, 2025
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.
suejung-sentry marked this conversation as resolved.
Show resolved Hide resolved

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()
suejung-sentry marked this conversation as resolved.
Show resolved Hide resolved
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