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 list unverified payment methods api #1115

Merged
merged 8 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions graphql_api/tests/test_billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from unittest.mock import patch

from django.test import TransactionTestCase
from shared.django_apps.codecov_auth.tests.factories import OwnerFactory
from stripe.api_resources import PaymentIntent, SetupIntent

from .helper import GraphQLTestHelper


class BillingTestCase(GraphQLTestHelper, TransactionTestCase):
def setUp(self):
self.owner = OwnerFactory(stripe_customer_id="test-customer-id")

def test_fetch_unverified_payment_methods(self):
query = """
query {
owner(username: "%s") {
billing {
unverifiedPaymentMethods {
paymentMethodId
hostedVerificationUrl
}
}
}
}
""" % (self.owner.username)

payment_intent = PaymentIntent.construct_from(
{
"payment_method": "pm_123",
"next_action": {
"type": "verify_with_microdeposits",
"verify_with_microdeposits": {
"hosted_verification_url": "https://verify.stripe.com/1"
},
},
},
"fake_api_key",
)

setup_intent = SetupIntent.construct_from(
{
"payment_method": "pm_456",
"next_action": {
"type": "verify_with_microdeposits",
"verify_with_microdeposits": {
"hosted_verification_url": "https://verify.stripe.com/2"
},
},
},
"fake_api_key",
)

with (
patch(
"services.billing.stripe.PaymentIntent.list"
) as payment_intent_list_mock,
patch("services.billing.stripe.SetupIntent.list") as setup_intent_list_mock,
):
payment_intent_list_mock.return_value.data = [payment_intent]
payment_intent_list_mock.return_value.has_more = False
setup_intent_list_mock.return_value.data = [setup_intent]
setup_intent_list_mock.return_value.has_more = False

result = self.gql_request(query, owner=self.owner)

assert "errors" not in result
data = result["owner"]["billing"]["unverifiedPaymentMethods"]
assert len(data) == 2
assert data[0]["paymentMethodId"] == "pm_123"
assert data[0]["hostedVerificationUrl"] == "https://verify.stripe.com/1"
assert data[1]["paymentMethodId"] == "pm_456"
assert data[1]["hostedVerificationUrl"] == "https://verify.stripe.com/2"
8 changes: 4 additions & 4 deletions graphql_api/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from ..helpers.ariadne import ariadne_load_local_graphql
from .account import account, account_bindable
from .billing import billing, billing_bindable
from .branch import branch, branch_bindable
from .bundle_analysis import (
bundle_analysis,
Expand All @@ -29,10 +30,7 @@
from .component import component, component_bindable
from .component_comparison import component_comparison, component_comparison_bindable
from .config import config, config_bindable
from .coverage_analytics import (
coverage_analytics,
coverage_analytics_bindable,
)
from .coverage_analytics import coverage_analytics, coverage_analytics_bindable
from .coverage_totals import coverage_totals, coverage_totals_bindable
from .enums import enum_types
from .file import commit_file, file_bindable
Expand Down Expand Up @@ -90,6 +88,7 @@
enums = ariadne_load_local_graphql(__file__, "./enums")
errors = ariadne_load_local_graphql(__file__, "./errors")
types = [
billing,
branch,
bundle_analysis_comparison,
bundle_analysis_report,
Expand Down Expand Up @@ -140,6 +139,7 @@
bindables = [
*enum_types.enum_types,
*mutation_resolvers,
billing_bindable,
branch_bindable,
bundle_analysis_comparison_bindable,
bundle_analysis_comparison_result_bindable,
Expand Down
8 changes: 8 additions & 0 deletions graphql_api/types/billing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from graphql_api.helpers.ariadne import ariadne_load_local_graphql

from .billing import billing_bindable

billing = ariadne_load_local_graphql(__file__, "billing.graphql")


__all__ = ["billing_bindable"]
8 changes: 8 additions & 0 deletions graphql_api/types/billing/billing.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type Billing {
unverifiedPaymentMethods: [UnverifiedPaymentMethod]
}

type UnverifiedPaymentMethod {
paymentMethodId: String!
hostedVerificationUrl: String
}
14 changes: 14 additions & 0 deletions graphql_api/types/billing/billing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from ariadne import ObjectType
from graphql import GraphQLResolveInfo

from codecov_auth.models import Owner
from services.billing import BillingService

billing_bindable = ObjectType("Billing")


@billing_bindable.field("unverifiedPaymentMethods")
def resolve_unverified_payment_methods(
owner: Owner, info: GraphQLResolveInfo
) -> list[dict]:
return BillingService(requesting_user=owner).get_unverified_payment_methods(owner)
1 change: 1 addition & 0 deletions graphql_api/types/owner/owner.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type Owner {
account: Account
availablePlans: [PlanRepresentation!]
avatarUrl: String!
billing: Billing
defaultOrgUsername: String
delinquent: Boolean
hashOwnerid: String
Expand Down
11 changes: 8 additions & 3 deletions graphql_api/types/owner/owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@
)
from codecov_auth.views.okta_cloud import OKTA_SIGNED_IN_ACCOUNTS_SESSION_KEY
from core.models import Repository
from graphql_api.actions.repository import (
list_repository_for_owner,
)
from graphql_api.actions.repository import list_repository_for_owner
from graphql_api.helpers.ariadne import ariadne_load_local_graphql
from graphql_api.helpers.connection import (
Connection,
Expand Down Expand Up @@ -402,3 +400,10 @@ def resolve_upload_token_required(
@require_shared_account_or_part_of_org
def resolve_activated_user_count(owner: Owner, info: GraphQLResolveInfo) -> int:
return owner.activated_user_count


@owner_bindable.field("billing")
@sync_to_async
@require_part_of_org
def resolve_billing(owner: Owner, info: GraphQLResolveInfo) -> dict | None:
return owner
88 changes: 81 additions & 7 deletions services/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@
import stripe
from dateutil.relativedelta import relativedelta
from django.conf import settings
from shared.plan.constants import (
PlanBillingRate,
TierName,
)
from shared.plan.constants import PlanBillingRate, TierName
from shared.plan.service import PlanService

from billing.constants import REMOVED_INVOICE_STATUSES
Expand Down Expand Up @@ -538,9 +535,11 @@
"metadata": self._get_checkout_session_and_subscription_metadata(owner),
},
tax_id_collection={"enabled": True},
customer_update={"name": "auto", "address": "auto"}
if owner.stripe_customer_id
else None,
customer_update=(
{"name": "auto", "address": "auto"}
if owner.stripe_customer_id
else None
),
)
log.info(
f"Stripe Checkout Session created successfully for owner {owner.ownerid} by user #{self.requesting_user.ownerid}"
Expand Down Expand Up @@ -722,6 +721,75 @@
customer=owner.stripe_customer_id,
)

@_log_stripe_error
def get_unverified_payment_methods(self, owner: Owner):
log.info(
"Getting unverified payment methods",
extra=dict(
owner_id=owner.ownerid, stripe_customer_id=owner.stripe_customer_id
),
)
if not owner.stripe_customer_id:
return []

Check warning on line 733 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L733

Added line #L733 was not covered by tests

unverified_payment_methods = []

# Check payment intents
has_more = True
starting_after = None
while has_more:
payment_intents = stripe.PaymentIntent.list(
customer=owner.stripe_customer_id,
limit=20,
starting_after=starting_after,
)
for intent in payment_intents.data or []:
if (
intent.get("next_action")
and intent.next_action
and intent.next_action.get("type") == "verify_with_microdeposits"
):
unverified_payment_methods.extend(
[
{
"payment_method_id": intent.payment_method,
"hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
}
]
)
has_more = payment_intents.has_more
if has_more and payment_intents.data:
starting_after = payment_intents.data[-1].id

# Check setup intents
has_more = True
starting_after = None
while has_more:
setup_intents = stripe.SetupIntent.list(
customer=owner.stripe_customer_id,
limit=20,
starting_after=starting_after,
)
for intent in setup_intents.data:
if (
intent.get("next_action")
and intent.next_action
and intent.next_action.get("type") == "verify_with_microdeposits"
):
unverified_payment_methods.extend(
[
{
"payment_method_id": intent.payment_method,
"hosted_verification_url": intent.next_action.verify_with_microdeposits.hosted_verification_url,
}
]
)
has_more = setup_intents.has_more
if has_more and setup_intents.data:
starting_after = setup_intents.data[-1].id

return unverified_payment_methods


class EnterprisePaymentService(AbstractPaymentService):
# enterprise has no payments setup so these are all noops
Expand Down Expand Up @@ -762,6 +830,9 @@
def create_setup_intent(self, owner):
pass

def get_unverified_payment_methods(self, owner: Owner):
pass

Check warning on line 834 in services/billing.py

View check run for this annotation

Codecov Notifications / codecov/patch

services/billing.py#L834

Added line #L834 was not covered by tests


class BillingService:
payment_service = None
Expand Down Expand Up @@ -792,6 +863,9 @@
def list_filtered_invoices(self, owner, limit=10):
return self.payment_service.list_filtered_invoices(owner, limit)

def get_unverified_payment_methods(self, owner: Owner):
return self.payment_service.get_unverified_payment_methods(owner)

def update_plan(self, owner, desired_plan):
"""
Takes an owner and desired plan, and updates the owner's plan. Depending
Expand Down
Loading
Loading