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

Commit 262e1c3

Browse files
Merge remote-tracking branch 'origin/main' into sshin/microdeposits-2
2 parents 51b6ef4 + 7e5a947 commit 262e1c3

File tree

9 files changed

+362
-12
lines changed

9 files changed

+362
-12
lines changed

graphql_api/tests/test_billing.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from unittest.mock import patch
2+
3+
from django.test import TransactionTestCase
4+
from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
5+
from stripe.api_resources import PaymentIntent, SetupIntent
6+
7+
from .helper import GraphQLTestHelper
8+
9+
10+
class BillingTestCase(GraphQLTestHelper, TransactionTestCase):
11+
def setUp(self):
12+
self.owner = OwnerFactory(stripe_customer_id="test-customer-id")
13+
14+
def test_fetch_unverified_payment_methods(self):
15+
query = """
16+
query {
17+
owner(username: "%s") {
18+
billing {
19+
unverifiedPaymentMethods {
20+
paymentMethodId
21+
hostedVerificationUrl
22+
}
23+
}
24+
}
25+
}
26+
""" % (self.owner.username)
27+
28+
payment_intent = PaymentIntent.construct_from(
29+
{
30+
"payment_method": "pm_123",
31+
"next_action": {
32+
"type": "verify_with_microdeposits",
33+
"verify_with_microdeposits": {
34+
"hosted_verification_url": "https://verify.stripe.com/1"
35+
},
36+
},
37+
},
38+
"fake_api_key",
39+
)
40+
41+
setup_intent = SetupIntent.construct_from(
42+
{
43+
"payment_method": "pm_456",
44+
"next_action": {
45+
"type": "verify_with_microdeposits",
46+
"verify_with_microdeposits": {
47+
"hosted_verification_url": "https://verify.stripe.com/2"
48+
},
49+
},
50+
},
51+
"fake_api_key",
52+
)
53+
54+
with (
55+
patch(
56+
"services.billing.stripe.PaymentIntent.list"
57+
) as payment_intent_list_mock,
58+
patch("services.billing.stripe.SetupIntent.list") as setup_intent_list_mock,
59+
):
60+
payment_intent_list_mock.return_value.data = [payment_intent]
61+
payment_intent_list_mock.return_value.has_more = False
62+
setup_intent_list_mock.return_value.data = [setup_intent]
63+
setup_intent_list_mock.return_value.has_more = False
64+
65+
result = self.gql_request(query, owner=self.owner)
66+
67+
assert "errors" not in result
68+
data = result["owner"]["billing"]["unverifiedPaymentMethods"]
69+
assert len(data) == 2
70+
assert data[0]["paymentMethodId"] == "pm_123"
71+
assert data[0]["hostedVerificationUrl"] == "https://verify.stripe.com/1"
72+
assert data[1]["paymentMethodId"] == "pm_456"
73+
assert data[1]["hostedVerificationUrl"] == "https://verify.stripe.com/2"

graphql_api/types/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from ..helpers.ariadne import ariadne_load_local_graphql
55
from .account import account, account_bindable
6+
from .billing import billing, billing_bindable
67
from .branch import branch, branch_bindable
78
from .bundle_analysis import (
89
bundle_analysis,
@@ -29,10 +30,7 @@
2930
from .component import component, component_bindable
3031
from .component_comparison import component_comparison, component_comparison_bindable
3132
from .config import config, config_bindable
32-
from .coverage_analytics import (
33-
coverage_analytics,
34-
coverage_analytics_bindable,
35-
)
33+
from .coverage_analytics import coverage_analytics, coverage_analytics_bindable
3634
from .coverage_totals import coverage_totals, coverage_totals_bindable
3735
from .enums import enum_types
3836
from .file import commit_file, file_bindable
@@ -90,6 +88,7 @@
9088
enums = ariadne_load_local_graphql(__file__, "./enums")
9189
errors = ariadne_load_local_graphql(__file__, "./errors")
9290
types = [
91+
billing,
9392
branch,
9493
bundle_analysis_comparison,
9594
bundle_analysis_report,
@@ -140,6 +139,7 @@
140139
bindables = [
141140
*enum_types.enum_types,
142141
*mutation_resolvers,
142+
billing_bindable,
143143
branch_bindable,
144144
bundle_analysis_comparison_bindable,
145145
bundle_analysis_comparison_result_bindable,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from graphql_api.helpers.ariadne import ariadne_load_local_graphql
2+
3+
from .billing import billing_bindable
4+
5+
billing = ariadne_load_local_graphql(__file__, "billing.graphql")
6+
7+
8+
__all__ = ["billing_bindable"]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
type Billing {
2+
unverifiedPaymentMethods: [UnverifiedPaymentMethod]
3+
}
4+
5+
type UnverifiedPaymentMethod {
6+
paymentMethodId: String!
7+
hostedVerificationUrl: String
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from ariadne import ObjectType
2+
from graphql import GraphQLResolveInfo
3+
4+
from codecov_auth.models import Owner
5+
from services.billing import BillingService
6+
7+
billing_bindable = ObjectType("Billing")
8+
9+
10+
@billing_bindable.field("unverifiedPaymentMethods")
11+
def resolve_unverified_payment_methods(
12+
owner: Owner, info: GraphQLResolveInfo
13+
) -> list[dict]:
14+
return BillingService(requesting_user=owner).get_unverified_payment_methods(owner)

graphql_api/types/owner/owner.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ type Owner {
22
account: Account
33
availablePlans: [PlanRepresentation!]
44
avatarUrl: String!
5+
billing: Billing
56
defaultOrgUsername: String
67
delinquent: Boolean
78
hashOwnerid: String

graphql_api/types/owner/owner.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
)
2727
from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY
2828
from core.models import Repository
29-
from graphql_api.actions.repository import (
30-
list_repository_for_owner,
31-
)
29+
from graphql_api.actions.repository import list_repository_for_owner
3230
from graphql_api.helpers.ariadne import ariadne_load_local_graphql
3331
from graphql_api.helpers.connection import (
3432
Connection,
@@ -402,3 +400,10 @@ def resolve_upload_token_required(
402400
@require_shared_account_or_part_of_org
403401
def resolve_activated_user_count(owner: Owner, info: GraphQLResolveInfo) -> int:
404402
return owner.activated_user_count
403+
404+
405+
@owner_bindable.field("billing")
406+
@sync_to_async
407+
@require_part_of_org
408+
def resolve_billing(owner: Owner, info: GraphQLResolveInfo) -> dict | None:
409+
return owner

services/billing.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import stripe
77
from dateutil.relativedelta import relativedelta
88
from django.conf import settings
9-
from shared.plan.constants import (
10-
PlanBillingRate,
11-
TierName,
12-
)
9+
from shared.plan.constants import PlanBillingRate, TierName
1310
from shared.plan.service import PlanService
1411

1512
from billing.constants import REMOVED_INVOICE_STATUSES
@@ -756,6 +753,75 @@ def create_setup_intent(self, owner: Owner) -> stripe.SetupIntent:
756753
customer=owner.stripe_customer_id,
757754
)
758755

756+
@_log_stripe_error
757+
def get_unverified_payment_methods(self, owner: Owner):
758+
log.info(
759+
"Getting unverified payment methods",
760+
extra=dict(
761+
owner_id=owner.ownerid, stripe_customer_id=owner.stripe_customer_id
762+
),
763+
)
764+
if not owner.stripe_customer_id:
765+
return []
766+
767+
unverified_payment_methods = []
768+
769+
# Check payment intents
770+
has_more = True
771+
starting_after = None
772+
while has_more:
773+
payment_intents = stripe.PaymentIntent.list(
774+
customer=owner.stripe_customer_id,
775+
limit=20,
776+
starting_after=starting_after,
777+
)
778+
for intent in payment_intents.data or []:
779+
if (
780+
intent.get("next_action")
781+
and intent.next_action
782+
and intent.next_action.get("type") == "verify_with_microdeposits"
783+
):
784+
unverified_payment_methods.extend(
785+
[
786+
{
787+
"payment_method_id": intent.payment_method,
788+
"hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
789+
}
790+
]
791+
)
792+
has_more = payment_intents.has_more
793+
if has_more and payment_intents.data:
794+
starting_after = payment_intents.data[-1].id
795+
796+
# Check setup intents
797+
has_more = True
798+
starting_after = None
799+
while has_more:
800+
setup_intents = stripe.SetupIntent.list(
801+
customer=owner.stripe_customer_id,
802+
limit=20,
803+
starting_after=starting_after,
804+
)
805+
for intent in setup_intents.data:
806+
if (
807+
intent.get("next_action")
808+
and intent.next_action
809+
and intent.next_action.get("type") == "verify_with_microdeposits"
810+
):
811+
unverified_payment_methods.extend(
812+
[
813+
{
814+
"payment_method_id": intent.payment_method,
815+
"hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
816+
}
817+
]
818+
)
819+
has_more = setup_intents.has_more
820+
if has_more and setup_intents.data:
821+
starting_after = setup_intents.data[-1].id
822+
823+
return unverified_payment_methods
824+
759825

760826
class EnterprisePaymentService(AbstractPaymentService):
761827
# enterprise has no payments setup so these are all noops
@@ -796,6 +862,9 @@ def apply_cancellation_discount(self, owner: Owner):
796862
def create_setup_intent(self, owner):
797863
pass
798864

865+
def get_unverified_payment_methods(self, owner: Owner):
866+
pass
867+
799868

800869
class BillingService:
801870
payment_service = None
@@ -826,6 +895,9 @@ def get_invoice(self, owner, invoice_id):
826895
def list_filtered_invoices(self, owner, limit=10):
827896
return self.payment_service.list_filtered_invoices(owner, limit)
828897

898+
def get_unverified_payment_methods(self, owner: Owner):
899+
return self.payment_service.get_unverified_payment_methods(owner)
900+
829901
def update_plan(self, owner, desired_plan):
830902
"""
831903
Takes an owner and desired plan, and updates the owner's plan. Depending

0 commit comments

Comments
 (0)