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

Commit 020e7cf

Browse files
feat: Add ACH payment method (#1083)
1 parent ddb4755 commit 020e7cf

File tree

18 files changed

+338
-13
lines changed

18 files changed

+338
-13
lines changed

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",
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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from unittest.mock import patch
2+
3+
from django.test import TransactionTestCase
4+
from shared.django_apps.core.tests.factories import OwnerFactory
5+
6+
from graphql_api.tests.helper import GraphQLTestHelper
7+
8+
query = """
9+
mutation($input: CreateStripeSetupIntentInput!) {
10+
createStripeSetupIntent(input: $input) {
11+
error {
12+
__typename
13+
}
14+
clientSecret
15+
}
16+
}
17+
"""
18+
19+
20+
class CreateStripeSetupIntentTestCase(GraphQLTestHelper, TransactionTestCase):
21+
def setUp(self):
22+
self.owner = OwnerFactory(username="codecov-user")
23+
24+
def test_when_unauthenticated(self):
25+
data = self.gql_request(query, variables={"input": {"owner": "somename"}})
26+
assert (
27+
data["createStripeSetupIntent"]["error"]["__typename"]
28+
== "UnauthenticatedError"
29+
)
30+
31+
def test_when_unauthorized(self):
32+
other_owner = OwnerFactory(username="other-user")
33+
data = self.gql_request(
34+
query,
35+
owner=self.owner,
36+
variables={"input": {"owner": other_owner.username}},
37+
)
38+
assert (
39+
data["createStripeSetupIntent"]["error"]["__typename"]
40+
== "UnauthorizedError"
41+
)
42+
43+
@patch("services.billing.stripe.SetupIntent.create")
44+
def test_when_validation_error(self, setup_intent_create_mock):
45+
setup_intent_create_mock.side_effect = Exception("Some error")
46+
data = self.gql_request(
47+
query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
48+
)
49+
assert (
50+
data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
51+
)
52+
53+
def test_when_owner_not_found(self):
54+
data = self.gql_request(
55+
query, owner=self.owner, variables={"input": {"owner": "nonexistent-user"}}
56+
)
57+
assert (
58+
data["createStripeSetupIntent"]["error"]["__typename"] == "ValidationError"
59+
)
60+
61+
@patch("services.billing.stripe.SetupIntent.create")
62+
def test_success(self, setup_intent_create_mock):
63+
setup_intent_create_mock.return_value = {"client_secret": "test-client-secret"}
64+
data = self.gql_request(
65+
query, owner=self.owner, variables={"input": {"owner": self.owner.username}}
66+
)
67+
assert data["createStripeSetupIntent"]["clientSecret"] == "test-client-secret"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
input CreateStripeSetupIntentInput {
2+
owner: String!
3+
}

graphql_api/types/invoice/invoice.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Period {
3434
type PaymentMethod {
3535
billingDetails: BillingDetails
3636
card: Card
37+
usBankAccount: USBankAccount
3738
}
3839

3940
type Card {
@@ -43,6 +44,11 @@ type Card {
4344
last4: String
4445
}
4546

47+
type USBankAccount {
48+
bankName: String
49+
last4: String
50+
}
51+
4652
type BillingDetails {
4753
address: Address
4854
email: String

graphql_api/types/mutation/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .activate_measurements import gql_activate_measurements
44
from .cancel_trial import gql_cancel_trial
55
from .create_api_token import gql_create_api_token
6+
from .create_stripe_setup_intent import gql_create_stripe_setup_intent
67
from .create_user_token import gql_create_user_token
78
from .delete_component_measurements import gql_delete_component_measurements
89
from .delete_flag import gql_delete_flag
@@ -31,6 +32,7 @@
3132

3233
mutation = ariadne_load_local_graphql(__file__, "mutation.graphql")
3334
mutation = mutation + gql_create_api_token
35+
mutation = mutation + gql_create_stripe_setup_intent
3436
mutation = mutation + gql_sync_with_git_provider
3537
mutation = mutation + gql_delete_session
3638
mutation = mutation + gql_set_yaml_on_owner

0 commit comments

Comments
 (0)