codecov-api icon indicating copy to clipboard operation
codecov-api copied to clipboard

feat: Add ACH webhook flows

Open suejung-sentry opened this issue 10 months ago • 5 comments

Handles ACH microdeposits delayed payment verification flow.

This PR handles the below new logical flows:

  1. User creates initial subscription from free to paid with ACH microdeposits as the selected payment method. In the 2 days it takes for that to be completed, what should they see in the UI?
    • added unverified_payment_methods list to GET /account-details
  2. User async verifies the ACH microdeposits for the initial checkout session
    • added webhook listener for payment_intent.succeeded
    • once verified, we set the account as the default payment method on customer, subscription, invoice_settings
    • we also upgrade the plan at this point (TODO - confirm this)
    • on Stripe's end the "charges_automatically" initital invoice that was pending gets successfully fulfilled
  3. User async verifies the ACH microdeposits for subsequent paymentMethodUpdate
    • added webhook listener for setup_intent.succeeded
    • once verified, we set the account as the default payment method on customer, subscription, invoice_settings
  4. The initial checkout session by Stripe has a "charges_automatically" that issues an invoice that fails when trying to use the ach microdeposits payment method.
    • ignore this case in the webhook listener for stripe invoice.payment_failed that does other stuff like marking the account delinquent
  5. If the user abandons their initial checkout session that has a pending ACH, we delete that abandoned one before offering a way to make a new one
    • added to the customer.subscription.deleted listener to ignore this case (vs. the one that wants to downgrade to free)
    • adjusted the upgrade_plan logic to handle when we have this incomplete subscription

Other things of note:

  • Stripe's list payment methods api does not included unverified paymentIntent / setupIntent

Tested end-to-end flows:

  1. Setup initial checkout session
    • Card
    • ACH instant
    • ACH microdeposits
  2. Change payment method
    • Card
    • ACH instant
    • ACH microdeposits
  3. Abandon ACH microdeposits

https://github.com/codecov/engineering-team/issues/2622

suejung-sentry avatar Jan 21 '25 23:01 suejung-sentry

Codecov Report

Attention: Patch coverage is 34.40860% with 61 lines in your changes missing coverage. Please review.

Project coverage is 95.56%. Comparing base (9f89174) to head (d163c61). Report is 3 commits behind head on main.

:white_check_mark: All tests successful. No failed tests found.

Files with missing lines Patch % Lines
services/billing.py 30.18% 37 Missing :warning:
billing/views.py 35.13% 24 Missing :warning:
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1106      +/-   ##
==========================================
- Coverage   96.09%   95.56%   -0.54%     
==========================================
  Files         832      835       +3     
  Lines       19507    19687     +180     
==========================================
+ Hits        18746    18813      +67     
- Misses        761      874     +113     
Flag Coverage Δ
unit 95.34% <34.40%> (-0.67%) :arrow_down:
unit-latest-uploader 95.34% <34.40%> (-0.67%) :arrow_down:

Flags with carried forward coverage won't be shown. Click here to find out more.

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

codecov[bot] avatar Jan 21 '25 23:01 codecov[bot]

:x: 47 Tests Failed:

Tests completed Failed Passed Skipped
2711 47 2664 7
View the top 3 failed tests by shortest run time
services/tests/test_billing.py::BillingServiceTests::test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists
Stack Traces | 0.009s run time
self = <services.tests.test_billing.BillingServiceTests testMethod=test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists>
delete_subscription_mock = <MagicMock name='delete_subscription' id='139656576365488'>
modify_subscription_mock = <MagicMock name='modify_subscription' id='139657646046000'>
create_checkout_session_mock = <MagicMock name='create_checkout_session' id='139656970200096'>
set_default_plan_data = <MagicMock name='set_default_plan_data' id='139656973899424'>

    @patch("shared.plan.service.PlanService.set_default_plan_data")
    @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
    @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
    @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
    def test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists(
        self,
        delete_subscription_mock,
        modify_subscription_mock,
        create_checkout_session_mock,
        set_default_plan_data,
    ):
        owner = OwnerFactory(stripe_subscription_id=10)
        desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
>       self.billing_service.update_plan(owner, desired_plan)

services/tests/test_billing.py:1939: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <services.billing.BillingService object at 0x7f04549fc860>
owner = <Owner: Owner<github/cochranmichael>>
desired_plan = {'quantity': 10, 'value': 'users-pr-inappy'}

    def update_plan(self, owner, desired_plan):
        """
        Takes an owner and desired plan, and updates the owner's plan. Depending
        on current state, might create a stripe checkout session and return
        the checkout session's ID, which is a string. Otherwise returns None.
        """
        if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS:
            if owner.stripe_subscription_id is not None:
                self.payment_service.delete_subscription(owner)
            else:
                plan_service = PlanService(current_org=owner)
                plan_service.set_default_plan_data()
        elif desired_plan["value"] in PAID_PLANS:
            if owner.stripe_subscription_id is not None:
                # if the existing subscription is incomplete, clean it up and create a new checkout session
>               subscription = self.payment_service.get_subscription(owner)
E               TypeError: MockPaymentService.get_subscription() missing 1 required positional argument: 'plan'

services/billing.py:891: TypeError
services/tests/test_billing.py::StripeServiceTests::test_update_payment_method
Stack Traces | 0.036s run time
self = <services.tests.test_billing.StripeServiceTests testMethod=test_update_payment_method>
modify_sub_mock = <MagicMock name='modify' id='139657641462480'>
modify_customer_mock = <MagicMock name='modify' id='139657648730576'>
attach_payment_mock = <MagicMock name='attach' id='139656613357152'>

    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self, modify_sub_mock, modify_customer_mock, attach_payment_mock
    ):
        payment_method_id = "pm_1234567"
        subscription_id = "sub_abc"
        customer_id = "cus_abc"
        owner = OwnerFactory(
            stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
        )
>       self.stripe.update_payment_method(owner, payment_method_id)

services/tests/test_billing.py:1627: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:597: in update_payment_method
    should_set_as_default = not self._is_unverified_payment_method(payment_method)
services/billing.py:549: in _is_unverified_payment_method
    payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
.../local/lib/python3.12.../site-packages/stripe/_payment_method.py:2755: in retrieve
    instance.refresh()
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:38: in refresh
    return self._request_and_refresh("get", self.instance_url())
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:128: in _request_and_refresh
    obj = StripeObject._request(
.../local/lib/python3.12.../site-packages/stripe/_stripe_object.py:406: in _request
    return self._requestor.request(
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:197: in request
    resp = requestor._interpret_response(rbody, rcode, rheaders, api_mode)
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:853: in _interpret_response
    self.handle_error_response(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <stripe._api_requestor._APIRequestor object at 0x7f0486d39e50>
rbody = '{\n  "error": {\n    "message": "Invalid API Key provided: default",\n    "type": "invalid_request_error"\n  }\n}\n'
rcode = 401
resp = OrderedDict({'error': OrderedDict({'message': 'Invalid API Key provided: default', 'type': 'invalid_request_error'})})
rheaders = {'Server': 'nginx', 'Date': 'Thu, 30 Jan 2025 03:34:40 GMT', 'Content-Type': 'application/json', 'Content-Length': '10...e': 'Bearer realm="Stripe"', 'X-Wc': 'AB', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload'}
api_mode = 'V1'

    def handle_error_response(
        self, rbody, rcode, resp, rheaders, api_mode
    ) -> NoReturn:
        try:
            error_data = resp["error"]
        except (KeyError, TypeError):
            raise error.APIError(
                "Invalid response object from API: %r (HTTP response code "
                "was %d)" % (rbody, rcode),
                rbody,
                rcode,
                resp,
            )
    
        err = None
    
        # OAuth errors are a JSON object where `error` is a string. In
        # contrast, in API errors, `error` is a hash with sub-keys. We use
        # this property to distinguish between OAuth and API errors.
        if isinstance(error_data, str):
            err = self.specific_oauth_error(
                rbody, rcode, resp, rheaders, error_data
            )
    
        if err is None:
            err = (
                self.specific_v2_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
                if api_mode == "V2"
                else self.specific_v1_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
            )
    
>       raise err
E       stripe._error.AuthenticationError: Invalid API Key provided: default

.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:336: AuthenticationError
api/internal/tests/views/test_account_viewset.py::AccountViewSetTests::test_update_payment_method
Stack Traces | 0.041s run time
self = <MagicMock name='attach' id='139657640678304'>, args = ('pm_123',)
kwargs = {'customer': 'flsoe'}
msg = "Expected 'attach' to be called once. Called 0 times."

    def assert_called_once_with(self, /, *args, **kwargs):
        """assert that the mock was called exactly once and that that call was
        with the specified arguments."""
        if not self.call_count == 1:
            msg = ("Expected '%s' to be called once. Called %s times.%s"
                   % (self._mock_name or 'mock',
                      self.call_count,
                      self._calls_repr()))
>           raise AssertionError(msg)
E           AssertionError: Expected 'attach' to be called once. Called 0 times.

.../local/lib/python3.12/unittest/mock.py:960: AssertionError

During handling of the above exception, another exception occurred:

self = <test_account_viewset.AccountViewSetTests testMethod=test_update_payment_method>
modify_subscription_mock = <MagicMock name='modify' id='139657641605552'>
modify_customer_mock = <MagicMock name='modify' id='139657645229168'>
attach_payment_mock = <MagicMock name='attach' id='139657640678304'>
retrieve_subscription_mock = <MagicMock name='retrieve' id='139657650848688'>

    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self,
        modify_subscription_mock,
        modify_customer_mock,
        attach_payment_mock,
        retrieve_subscription_mock,
    ):
        self.current_owner.stripe_customer_id = "flsoe"
        self.current_owner.stripe_subscription_id = "djfos"
        self.current_owner.save()
        f = open("..../tests/samples/stripe_invoice.json")
    
        default_payment_method = {
            "card": {
                "brand": "visa",
                "exp_month": 12,
                "exp_year": 2024,
                "last4": "abcd",
                "should be": "removed",
            }
        }
    
        subscription_params = {
            "default_payment_method": default_payment_method,
            "cancel_at_period_end": False,
            "current_period_end": 1633512445,
            "latest_invoice": json.load(f)["data"][0],
            "schedule_id": None,
            "collection_method": "charge_automatically",
            "tax_ids": None,
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    
        payment_method_id = "pm_123"
        kwargs = {
            "service": self.current_owner.service,
            "owner_username": self.current_owner.username,
        }
        data = {"payment_method": payment_method_id}
        url = reverse("account_details-update-payment", kwargs=kwargs)
        response = self.client.patch(url, data=data, format="json")
        assert response.status_code == status.HTTP_200_OK
>       attach_payment_mock.assert_called_once_with(
            payment_method_id, customer=self.current_owner.stripe_customer_id
        )
E       AssertionError: Expected 'attach' to be called once. Called 0 times.

.../tests/views/test_account_viewset.py:1148: AssertionError

To view more test analytics, go to the Test Analytics Dashboard :loudspeaker: Thoughts on this report? Let us know!

:x: 47 Tests Failed:

Tests completed Failed Passed Skipped
2718 47 2665 6
View the top 3 failed tests by shortest run time
test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists
Stack Traces | 0.009s run time
self = &lt;services.tests.test_billing.BillingServiceTests testMethod=test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists&gt;
delete_subscription_mock = &lt;MagicMock name='delete_subscription' id='139656576365488'&gt;
modify_subscription_mock = &lt;MagicMock name='modify_subscription' id='139657646046000'&gt;
create_checkout_session_mock = &lt;MagicMock name='create_checkout_session' id='139656970200096'&gt;
set_default_plan_data = &lt;MagicMock name='set_default_plan_data' id='139656973899424'&gt;

    @patch("shared.plan.service.PlanService.set_default_plan_data")
    @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
    @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
    @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
    def test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists(
        self,
        delete_subscription_mock,
        modify_subscription_mock,
        create_checkout_session_mock,
        set_default_plan_data,
    ):
        owner = OwnerFactory(stripe_subscription_id=10)
        desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
&gt;       self.billing_service.update_plan(owner, desired_plan)

services/tests/test_billing.py:1939: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;services.billing.BillingService object at 0x7f04549fc860&gt;
owner = &lt;Owner: Owner&lt;github/cochranmichael&gt;&gt;
desired_plan = {'quantity': 10, 'value': 'users-pr-inappy'}

    def update_plan(self, owner, desired_plan):
        """
        Takes an owner and desired plan, and updates the owner's plan. Depending
        on current state, might create a stripe checkout session and return
        the checkout session's ID, which is a string. Otherwise returns None.
        """
        if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS:
            if owner.stripe_subscription_id is not None:
                self.payment_service.delete_subscription(owner)
            else:
                plan_service = PlanService(current_org=owner)
                plan_service.set_default_plan_data()
        elif desired_plan["value"] in PAID_PLANS:
            if owner.stripe_subscription_id is not None:
                # if the existing subscription is incomplete, clean it up and create a new checkout session
&gt;               subscription = self.payment_service.get_subscription(owner)
E               TypeError: MockPaymentService.get_subscription() missing 1 required positional argument: 'plan'

services/billing.py:891: TypeError
test_update_payment_method
Stack Traces | 0.036s run time
self = &lt;services.tests.test_billing.StripeServiceTests testMethod=test_update_payment_method&gt;
modify_sub_mock = &lt;MagicMock name='modify' id='139657641462480'&gt;
modify_customer_mock = &lt;MagicMock name='modify' id='139657648730576'&gt;
attach_payment_mock = &lt;MagicMock name='attach' id='139656613357152'&gt;

    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self, modify_sub_mock, modify_customer_mock, attach_payment_mock
    ):
        payment_method_id = "pm_1234567"
        subscription_id = "sub_abc"
        customer_id = "cus_abc"
        owner = OwnerFactory(
            stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
        )
&gt;       self.stripe.update_payment_method(owner, payment_method_id)

services/tests/test_billing.py:1627: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:597: in update_payment_method
    should_set_as_default = not self._is_unverified_payment_method(payment_method)
services/billing.py:549: in _is_unverified_payment_method
    payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
/usr/local/lib/python3.12/site-packages/stripe/_payment_method.py:2755: in retrieve
    instance.refresh()
/usr/local/lib/python3.12/site-packages/stripe/_api_resource.py:38: in refresh
    return self._request_and_refresh("get", self.instance_url())
/usr/local/lib/python3.12/site-packages/stripe/_api_resource.py:128: in _request_and_refresh
    obj = StripeObject._request(
/usr/local/lib/python3.12/site-packages/stripe/_stripe_object.py:406: in _request
    return self._requestor.request(
/usr/local/lib/python3.12/site-packages/stripe/_api_requestor.py:197: in request
    resp = requestor._interpret_response(rbody, rcode, rheaders, api_mode)
/usr/local/lib/python3.12/site-packages/stripe/_api_requestor.py:853: in _interpret_response
    self.handle_error_response(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;stripe._api_requestor._APIRequestor object at 0x7f0486d39e50&gt;
rbody = '{\n  "error": {\n    "message": "Invalid API Key provided: default",\n    "type": "invalid_request_error"\n  }\n}\n'
rcode = 401
resp = OrderedDict({'error': OrderedDict({'message': 'Invalid API Key provided: default', 'type': 'invalid_request_error'})})
rheaders = {'Server': 'nginx', 'Date': 'Thu, 30 Jan 2025 03:34:40 GMT', 'Content-Type': 'application/json', 'Content-Length': '10...e': 'Bearer realm="Stripe"', 'X-Wc': 'AB', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload'}
api_mode = 'V1'

    def handle_error_response(
        self, rbody, rcode, resp, rheaders, api_mode
    ) -&gt; NoReturn:
        try:
            error_data = resp["error"]
        except (KeyError, TypeError):
            raise error.APIError(
                "Invalid response object from API: %r (HTTP response code "
                "was %d)" % (rbody, rcode),
                rbody,
                rcode,
                resp,
            )
    
        err = None
    
        # OAuth errors are a JSON object where `error` is a string. In
        # contrast, in API errors, `error` is a hash with sub-keys. We use
        # this property to distinguish between OAuth and API errors.
        if isinstance(error_data, str):
            err = self.specific_oauth_error(
                rbody, rcode, resp, rheaders, error_data
            )
    
        if err is None:
            err = (
                self.specific_v2_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
                if api_mode == "V2"
                else self.specific_v1_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
            )
    
&gt;       raise err
E       stripe._error.AuthenticationError: Invalid API Key provided: default

/usr/local/lib/python3.12/site-packages/stripe/_api_requestor.py:336: AuthenticationError
test_update_payment_method
Stack Traces | 0.041s run time
self = &lt;MagicMock name='attach' id='139657640678304'&gt;, args = ('pm_123',)
kwargs = {'customer': 'flsoe'}
msg = "Expected 'attach' to be called once. Called 0 times."

    def assert_called_once_with(self, /, *args, **kwargs):
        """assert that the mock was called exactly once and that that call was
        with the specified arguments."""
        if not self.call_count == 1:
            msg = ("Expected '%s' to be called once. Called %s times.%s"
                   % (self._mock_name or 'mock',
                      self.call_count,
                      self._calls_repr()))
&gt;           raise AssertionError(msg)
E           AssertionError: Expected 'attach' to be called once. Called 0 times.

/usr/local/lib/python3.12/unittest/mock.py:960: AssertionError

During handling of the above exception, another exception occurred:

self = &lt;test_account_viewset.AccountViewSetTests testMethod=test_update_payment_method&gt;
modify_subscription_mock = &lt;MagicMock name='modify' id='139657641605552'&gt;
modify_customer_mock = &lt;MagicMock name='modify' id='139657645229168'&gt;
attach_payment_mock = &lt;MagicMock name='attach' id='139657640678304'&gt;
retrieve_subscription_mock = &lt;MagicMock name='retrieve' id='139657650848688'&gt;

    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self,
        modify_subscription_mock,
        modify_customer_mock,
        attach_payment_mock,
        retrieve_subscription_mock,
    ):
        self.current_owner.stripe_customer_id = "flsoe"
        self.current_owner.stripe_subscription_id = "djfos"
        self.current_owner.save()
        f = open("./services/tests/samples/stripe_invoice.json")
    
        default_payment_method = {
            "card": {
                "brand": "visa",
                "exp_month": 12,
                "exp_year": 2024,
                "last4": "abcd",
                "should be": "removed",
            }
        }
    
        subscription_params = {
            "default_payment_method": default_payment_method,
            "cancel_at_period_end": False,
            "current_period_end": 1633512445,
            "latest_invoice": json.load(f)["data"][0],
            "schedule_id": None,
            "collection_method": "charge_automatically",
            "tax_ids": None,
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    
        payment_method_id = "pm_123"
        kwargs = {
            "service": self.current_owner.service,
            "owner_username": self.current_owner.username,
        }
        data = {"payment_method": payment_method_id}
        url = reverse("account_details-update-payment", kwargs=kwargs)
        response = self.client.patch(url, data=data, format="json")
        assert response.status_code == status.HTTP_200_OK
&gt;       attach_payment_mock.assert_called_once_with(
            payment_method_id, customer=self.current_owner.stripe_customer_id
        )
E       AssertionError: Expected 'attach' to be called once. Called 0 times.

api/internal/tests/views/test_account_viewset.py:1148: AssertionError

:mega: Thoughts on this report? Let Codecov know! | Powered by Codecov

github-actions[bot] avatar Jan 21 '25 23:01 github-actions[bot]

:x: 47 Tests Failed:

Tests completed Failed Passed Skipped
2711 47 2664 7
View the top 3 failed tests by shortest run time
services/tests/test_billing.py::BillingServiceTests::test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists
Stack Traces | 0.009s run time
self = <services.tests.test_billing.BillingServiceTests testMethod=test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists>
delete_subscription_mock = <MagicMock name='delete_subscription' id='139656576365488'>
modify_subscription_mock = <MagicMock name='modify_subscription' id='139657646046000'>
create_checkout_session_mock = <MagicMock name='create_checkout_session' id='139656970200096'>
set_default_plan_data = <MagicMock name='set_default_plan_data' id='139656973899424'>

    @patch("shared.plan.service.PlanService.set_default_plan_data")
    @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
    @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
    @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
    def test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists(
        self,
        delete_subscription_mock,
        modify_subscription_mock,
        create_checkout_session_mock,
        set_default_plan_data,
    ):
        owner = OwnerFactory(stripe_subscription_id=10)
        desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
>       self.billing_service.update_plan(owner, desired_plan)

services/tests/test_billing.py:1939: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <services.billing.BillingService object at 0x7f04549fc860>
owner = <Owner: Owner<github/cochranmichael>>
desired_plan = {'quantity': 10, 'value': 'users-pr-inappy'}

    def update_plan(self, owner, desired_plan):
        """
        Takes an owner and desired plan, and updates the owner's plan. Depending
        on current state, might create a stripe checkout session and return
        the checkout session's ID, which is a string. Otherwise returns None.
        """
        if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS:
            if owner.stripe_subscription_id is not None:
                self.payment_service.delete_subscription(owner)
            else:
                plan_service = PlanService(current_org=owner)
                plan_service.set_default_plan_data()
        elif desired_plan["value"] in PAID_PLANS:
            if owner.stripe_subscription_id is not None:
                # if the existing subscription is incomplete, clean it up and create a new checkout session
>               subscription = self.payment_service.get_subscription(owner)
E               TypeError: MockPaymentService.get_subscription() missing 1 required positional argument: 'plan'

services/billing.py:891: TypeError
services/tests/test_billing.py::StripeServiceTests::test_update_payment_method
Stack Traces | 0.036s run time
self = <services.tests.test_billing.StripeServiceTests testMethod=test_update_payment_method>
modify_sub_mock = <MagicMock name='modify' id='139657641462480'>
modify_customer_mock = <MagicMock name='modify' id='139657648730576'>
attach_payment_mock = <MagicMock name='attach' id='139656613357152'>

    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self, modify_sub_mock, modify_customer_mock, attach_payment_mock
    ):
        payment_method_id = "pm_1234567"
        subscription_id = "sub_abc"
        customer_id = "cus_abc"
        owner = OwnerFactory(
            stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
        )
>       self.stripe.update_payment_method(owner, payment_method_id)

services/tests/test_billing.py:1627: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:597: in update_payment_method
    should_set_as_default = not self._is_unverified_payment_method(payment_method)
services/billing.py:549: in _is_unverified_payment_method
    payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
.../local/lib/python3.12.../site-packages/stripe/_payment_method.py:2755: in retrieve
    instance.refresh()
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:38: in refresh
    return self._request_and_refresh("get", self.instance_url())
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:128: in _request_and_refresh
    obj = StripeObject._request(
.../local/lib/python3.12.../site-packages/stripe/_stripe_object.py:406: in _request
    return self._requestor.request(
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:197: in request
    resp = requestor._interpret_response(rbody, rcode, rheaders, api_mode)
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:853: in _interpret_response
    self.handle_error_response(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <stripe._api_requestor._APIRequestor object at 0x7f0486d39e50>
rbody = '{\n  "error": {\n    "message": "Invalid API Key provided: default",\n    "type": "invalid_request_error"\n  }\n}\n'
rcode = 401
resp = OrderedDict({'error': OrderedDict({'message': 'Invalid API Key provided: default', 'type': 'invalid_request_error'})})
rheaders = {'Server': 'nginx', 'Date': 'Thu, 30 Jan 2025 03:34:40 GMT', 'Content-Type': 'application/json', 'Content-Length': '10...e': 'Bearer realm="Stripe"', 'X-Wc': 'AB', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload'}
api_mode = 'V1'

    def handle_error_response(
        self, rbody, rcode, resp, rheaders, api_mode
    ) -> NoReturn:
        try:
            error_data = resp["error"]
        except (KeyError, TypeError):
            raise error.APIError(
                "Invalid response object from API: %r (HTTP response code "
                "was %d)" % (rbody, rcode),
                rbody,
                rcode,
                resp,
            )
    
        err = None
    
        # OAuth errors are a JSON object where `error` is a string. In
        # contrast, in API errors, `error` is a hash with sub-keys. We use
        # this property to distinguish between OAuth and API errors.
        if isinstance(error_data, str):
            err = self.specific_oauth_error(
                rbody, rcode, resp, rheaders, error_data
            )
    
        if err is None:
            err = (
                self.specific_v2_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
                if api_mode == "V2"
                else self.specific_v1_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
            )
    
>       raise err
E       stripe._error.AuthenticationError: Invalid API Key provided: default

.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:336: AuthenticationError
api/internal/tests/views/test_account_viewset.py::AccountViewSetTests::test_update_payment_method
Stack Traces | 0.041s run time
self = <MagicMock name='attach' id='139657640678304'>, args = ('pm_123',)
kwargs = {'customer': 'flsoe'}
msg = "Expected 'attach' to be called once. Called 0 times."

    def assert_called_once_with(self, /, *args, **kwargs):
        """assert that the mock was called exactly once and that that call was
        with the specified arguments."""
        if not self.call_count == 1:
            msg = ("Expected '%s' to be called once. Called %s times.%s"
                   % (self._mock_name or 'mock',
                      self.call_count,
                      self._calls_repr()))
>           raise AssertionError(msg)
E           AssertionError: Expected 'attach' to be called once. Called 0 times.

.../local/lib/python3.12/unittest/mock.py:960: AssertionError

During handling of the above exception, another exception occurred:

self = <test_account_viewset.AccountViewSetTests testMethod=test_update_payment_method>
modify_subscription_mock = <MagicMock name='modify' id='139657641605552'>
modify_customer_mock = <MagicMock name='modify' id='139657645229168'>
attach_payment_mock = <MagicMock name='attach' id='139657640678304'>
retrieve_subscription_mock = <MagicMock name='retrieve' id='139657650848688'>

    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self,
        modify_subscription_mock,
        modify_customer_mock,
        attach_payment_mock,
        retrieve_subscription_mock,
    ):
        self.current_owner.stripe_customer_id = "flsoe"
        self.current_owner.stripe_subscription_id = "djfos"
        self.current_owner.save()
        f = open("..../tests/samples/stripe_invoice.json")
    
        default_payment_method = {
            "card": {
                "brand": "visa",
                "exp_month": 12,
                "exp_year": 2024,
                "last4": "abcd",
                "should be": "removed",
            }
        }
    
        subscription_params = {
            "default_payment_method": default_payment_method,
            "cancel_at_period_end": False,
            "current_period_end": 1633512445,
            "latest_invoice": json.load(f)["data"][0],
            "schedule_id": None,
            "collection_method": "charge_automatically",
            "tax_ids": None,
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    
        payment_method_id = "pm_123"
        kwargs = {
            "service": self.current_owner.service,
            "owner_username": self.current_owner.username,
        }
        data = {"payment_method": payment_method_id}
        url = reverse("account_details-update-payment", kwargs=kwargs)
        response = self.client.patch(url, data=data, format="json")
        assert response.status_code == status.HTTP_200_OK
>       attach_payment_mock.assert_called_once_with(
            payment_method_id, customer=self.current_owner.stripe_customer_id
        )
E       AssertionError: Expected 'attach' to be called once. Called 0 times.

.../tests/views/test_account_viewset.py:1148: AssertionError

To view more test analytics, go to the Test Analytics Dashboard :loudspeaker: Thoughts on this report? Let us know!

codecov-qa[bot] avatar Jan 23 '25 23:01 codecov-qa[bot]

:x: 47 Tests Failed:

Tests completed Failed Passed Skipped
2712 47 2665 6
View the top 3 failed tests by shortest run time
services/tests/test_billing.py::BillingServiceTests::test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists
Stack Traces | 0.009s run time
self = <services.tests.test_billing.BillingServiceTests testMethod=test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists>
delete_subscription_mock = <MagicMock name='delete_subscription' id='139656576365488'>
modify_subscription_mock = <MagicMock name='modify_subscription' id='139657646046000'>
create_checkout_session_mock = <MagicMock name='create_checkout_session' id='139656970200096'>
set_default_plan_data = <MagicMock name='set_default_plan_data' id='139656973899424'>

    @patch("shared.plan.service.PlanService.set_default_plan_data")
    @patch("services.tests.test_billing.MockPaymentService.create_checkout_session")
    @patch("services.tests.test_billing.MockPaymentService.modify_subscription")
    @patch("services.tests.test_billing.MockPaymentService.delete_subscription")
    def test_update_plan_modifies_subscription_if_user_plan_and_subscription_exists(
        self,
        delete_subscription_mock,
        modify_subscription_mock,
        create_checkout_session_mock,
        set_default_plan_data,
    ):
        owner = OwnerFactory(stripe_subscription_id=10)
        desired_plan = {"value": PlanName.CODECOV_PRO_YEARLY.value, "quantity": 10}
>       self.billing_service.update_plan(owner, desired_plan)

services/tests/test_billing.py:1939: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <services.billing.BillingService object at 0x7f04549fc860>
owner = <Owner: Owner<github/cochranmichael>>
desired_plan = {'quantity': 10, 'value': 'users-pr-inappy'}

    def update_plan(self, owner, desired_plan):
        """
        Takes an owner and desired plan, and updates the owner's plan. Depending
        on current state, might create a stripe checkout session and return
        the checkout session's ID, which is a string. Otherwise returns None.
        """
        if desired_plan["value"] in FREE_PLAN_REPRESENTATIONS:
            if owner.stripe_subscription_id is not None:
                self.payment_service.delete_subscription(owner)
            else:
                plan_service = PlanService(current_org=owner)
                plan_service.set_default_plan_data()
        elif desired_plan["value"] in PAID_PLANS:
            if owner.stripe_subscription_id is not None:
                # if the existing subscription is incomplete, clean it up and create a new checkout session
>               subscription = self.payment_service.get_subscription(owner)
E               TypeError: MockPaymentService.get_subscription() missing 1 required positional argument: 'plan'

services/billing.py:891: TypeError
services/tests/test_billing.py::StripeServiceTests::test_update_payment_method
Stack Traces | 0.036s run time
self = <services.tests.test_billing.StripeServiceTests testMethod=test_update_payment_method>
modify_sub_mock = <MagicMock name='modify' id='139657641462480'>
modify_customer_mock = <MagicMock name='modify' id='139657648730576'>
attach_payment_mock = <MagicMock name='attach' id='139656613357152'>

    @patch("services.billing.stripe.PaymentMethod.attach")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Subscription.modify")
    def test_update_payment_method(
        self, modify_sub_mock, modify_customer_mock, attach_payment_mock
    ):
        payment_method_id = "pm_1234567"
        subscription_id = "sub_abc"
        customer_id = "cus_abc"
        owner = OwnerFactory(
            stripe_subscription_id=subscription_id, stripe_customer_id=customer_id
        )
>       self.stripe.update_payment_method(owner, payment_method_id)

services/tests/test_billing.py:1627: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:597: in update_payment_method
    should_set_as_default = not self._is_unverified_payment_method(payment_method)
services/billing.py:549: in _is_unverified_payment_method
    payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
.../local/lib/python3.12.../site-packages/stripe/_payment_method.py:2755: in retrieve
    instance.refresh()
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:38: in refresh
    return self._request_and_refresh("get", self.instance_url())
.../local/lib/python3.12....../site-packages/stripe/_api_resource.py:128: in _request_and_refresh
    obj = StripeObject._request(
.../local/lib/python3.12.../site-packages/stripe/_stripe_object.py:406: in _request
    return self._requestor.request(
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:197: in request
    resp = requestor._interpret_response(rbody, rcode, rheaders, api_mode)
.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:853: in _interpret_response
    self.handle_error_response(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <stripe._api_requestor._APIRequestor object at 0x7f0486d39e50>
rbody = '{\n  "error": {\n    "message": "Invalid API Key provided: default",\n    "type": "invalid_request_error"\n  }\n}\n'
rcode = 401
resp = OrderedDict({'error': OrderedDict({'message': 'Invalid API Key provided: default', 'type': 'invalid_request_error'})})
rheaders = {'Server': 'nginx', 'Date': 'Thu, 30 Jan 2025 03:34:40 GMT', 'Content-Type': 'application/json', 'Content-Length': '10...e': 'Bearer realm="Stripe"', 'X-Wc': 'AB', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload'}
api_mode = 'V1'

    def handle_error_response(
        self, rbody, rcode, resp, rheaders, api_mode
    ) -> NoReturn:
        try:
            error_data = resp["error"]
        except (KeyError, TypeError):
            raise error.APIError(
                "Invalid response object from API: %r (HTTP response code "
                "was %d)" % (rbody, rcode),
                rbody,
                rcode,
                resp,
            )
    
        err = None
    
        # OAuth errors are a JSON object where `error` is a string. In
        # contrast, in API errors, `error` is a hash with sub-keys. We use
        # this property to distinguish between OAuth and API errors.
        if isinstance(error_data, str):
            err = self.specific_oauth_error(
                rbody, rcode, resp, rheaders, error_data
            )
    
        if err is None:
            err = (
                self.specific_v2_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
                if api_mode == "V2"
                else self.specific_v1_api_error(
                    rbody, rcode, resp, rheaders, error_data
                )
            )
    
>       raise err
E       stripe._error.AuthenticationError: Invalid API Key provided: default

.../local/lib/python3.12........./site-packages/stripe/_api_requestor.py:336: AuthenticationError
api/internal/tests/views/test_account_viewset.py::AccountViewSetTests::test_update_sentry_plan_monthly
Stack Traces | 0.041s run time
self = <MagicMock name='send_sentry_webhook' id='139657647271152'>
args = (<Owner: Owner<github/brianbrady>>, <Owner: Owner<github/brianbrady>>)
kwargs = {}
msg = "Expected 'send_sentry_webhook' to be called once. Called 0 times."

    def assert_called_once_with(self, /, *args, **kwargs):
        """assert that the mock was called exactly once and that that call was
        with the specified arguments."""
        if not self.call_count == 1:
            msg = ("Expected '%s' to be called once. Called %s times.%s"
                   % (self._mock_name or 'mock',
                      self.call_count,
                      self._calls_repr()))
>           raise AssertionError(msg)
E           AssertionError: Expected 'send_sentry_webhook' to be called once. Called 0 times.

.../local/lib/python3.12/unittest/mock.py:960: AssertionError

During handling of the above exception, another exception occurred:

self = <test_account_viewset.AccountViewSetTests testMethod=test_update_sentry_plan_monthly>
modify_sub_mock = <MagicMock name='modify_subscription' id='139657647273456'>
send_sentry_webhook = <MagicMock name='send_sentry_webhook' id='139657647271152'>

    @patch("api.internal.owner.serializers.send_sentry_webhook")
    @patch("services.billing.StripeService.modify_subscription")
    def test_update_sentry_plan_monthly(self, modify_sub_mock, send_sentry_webhook):
        desired_plan = {"value": PlanName.SENTRY_MONTHLY.value, "quantity": 12}
        self.current_owner.stripe_customer_id = "flsoe"
        self.current_owner.stripe_subscription_id = "djfos"
        self.current_owner.sentry_user_id = "sentry-user-id"
        self.current_owner.save()
    
        self._update(
            kwargs={
                "service": self.current_owner.service,
                "owner_username": self.current_owner.username,
            },
            data={"plan": desired_plan},
        )
>       send_sentry_webhook.assert_called_once_with(
            self.current_owner, self.current_owner
        )
E       AssertionError: Expected 'send_sentry_webhook' to be called once. Called 0 times.

.../tests/views/test_account_viewset.py:1469: AssertionError

To view more test analytics, go to the Test Analytics Dashboard :loudspeaker: Thoughts on this report? Let us know!

codecov-public-qa[bot] avatar Jan 23 '25 23:01 codecov-public-qa[bot]