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

Stripe Auto-refund cancellation within Grace Period #894

Merged
merged 17 commits into from
Oct 29, 2024

Conversation

calvin-codecov
Copy link
Contributor

@calvin-codecov calvin-codecov commented Oct 16, 2024

Purpose/Motivation

What is the feature? Why is this being done?
Adding auto-refund logic for when a user cancels their subscription within 24 hours (monthly charge) or 72 hours (yearly charge) of the period starting and being charged.

Open to any and all feedback including styling and syntax recommendations as this is one of my first python/codecov-api PRs. Should I wrap Stripe API calls in a try/catch?

Links to relevant tickets

Closes codecov/engineering-team#2508

What does this PR do?

Our current logic is that when someone cancels, since they have already paid for their current period (either a month or a year), we just "modify" there subscription to cancel at period end. We will still keep this logic for when they cancel out of the grace period but within the grace period, we will cancel immediately and refund them their money.

Doing now:

  • grabs the current period's start timestamp and compares it to current time
  • checks to see if it is within 1 day if monthly or 3 days if yearly
  • if it is, check if they have exceeded their limit of 2 autorefund instances
    • we cancel the current subscription instead of modifying it
  • look through the invoices on the subscription from within the last period and create refunds for each that is paid and has a created date before the start of the period
  • modify the customer to have a balance of 0
  • if not within the grace period or they've exceeded their limit, we modify the subscription to end at period end like we currently do

Notable change

  • we are managing the limit of autorefunds through stripe Customer object's metadata field {"autorefunds_remaining: int"} as opposed to making a new column in the DB Owners table
  • this allows anyone with Stripe access to modify for a customer if needed
  • Drawback: a bad actor could theoretically keep making new Stripe accounts to get around this and get free Codecov if they cancelled every 3 days as this limit is imposed on a stripe Customer as opposed to a Codecov owner. We will be logging each time an autorefund is fired to get a sense of how often it is happened and if users are dropping down to 0 to decide if this is a problem.

@codecov-notifications
Copy link

codecov-notifications bot commented Oct 16, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Copy link

codecov bot commented Oct 16, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 96.23%. Comparing base (142a003) to head (538d4be).
Report is 1 commits behind head on main.

✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #894   +/-   ##
=======================================
  Coverage   96.23%   96.23%           
=======================================
  Files         823      823           
  Lines       18972    19002   +30     
=======================================
+ Hits        18257    18287   +30     
  Misses        715      715           
Flag Coverage Δ
unit 92.48% <100.00%> (+0.01%) ⬆️
unit-latest-uploader 92.48% <100.00%> (+0.01%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@calvin-codecov calvin-codecov changed the title Cy/cancellation grace Stripe Auto-refund cancellation within Grace Period Oct 16, 2024
Copy link
Contributor

@nora-codecov nora-codecov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some comments and slacked you with a few additional thoughts 👍

cancel_at_period_end=True,
proration_behavior="none",
# we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
# current_subscription_timestamp = subscription["current_period_start"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you mean to leave this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, for some descriptuon

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not the second line. oops

)
differenceFromNow = datetime.now() - current_subscription_datetime
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snake pls!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, haha habit

)
within_refund_grace_period = (
subscription_plan_interval == "month" and differenceFromNow.days < 1
) or (subscription_plan_interval == "year" and differenceFromNow.days < 3)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does .days operate the way you expect? Did you check for weird rounding errors in how it defines a day?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.days is defined as full days so it will be an integer

services/billing.py Show resolved Hide resolved
)
differenceFromNow = datetime.now() - current_subscription_datetime

subscription_plan_interval = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is equivalent to subscription_plan_interval = getattr(subscription.plan, "interval", None) I believe

):
stripe.Refund.create(invoice["charge"])
created_refund = True
if created_refund == True:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can get rid of this secondary if (and remove created_refund) and have this clause be in the top level statement with what you have in the else as an else for the conditional starting on 198, right?

stripe.Customer.modify( owner.stripe_customer_id, balance=0, )

@calvin-codecov calvin-codecov force-pushed the cy/cancellation_grace branch 2 times, most recently from 0a029bb to 8432340 Compare October 24, 2024 18:45
@codecov-qa
Copy link

codecov-qa bot commented Oct 24, 2024

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
2641 3 2638 6
View the top 3 failed tests by shortest run time
services/tests/test_billing.py::StripeServiceTests::test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund
Stack Traces | 0.037s run time
self = &lt;services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund&gt;
schedule_release_mock = &lt;MagicMock name='release' id='140181302589056'&gt;
retrieve_subscription_mock = &lt;MagicMock name='retrieve' id='140181299640608'&gt;
cancel_sub_mock = &lt;MagicMock name='cancel' id='140181301587424'&gt;
list_invoice_mock = &lt;MagicMock name='list' id='140181299539904'&gt;
create_refund_mock = &lt;MagicMock name='create' id='140181301532800'&gt;
modify_customer_mock = &lt;MagicMock name='modify' id='140181297816896'&gt;
modify_sub_mock = &lt;MagicMock name='modify' id='140181297814736'&gt;
retrieve_customer_mock = &lt;MagicMock name='retrieve' id='140181297813056'&gt;

    @freeze_time("2017-03-19T00:00:00")
    @patch("services.billing.stripe.Customer.retrieve")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund(
        self,
        schedule_release_mock,
        retrieve_subscription_mock,
        cancel_sub_mock,
        list_invoice_mock,
        create_refund_mock,
        modify_customer_mock,
        modify_sub_mock,
        retrieve_customer_mock,
    ):
        with open("..../tests/samples/stripe_invoice.json") as f:
            stripe_invoice_response = json.load(f)
        for invoice in stripe_invoice_response["data"]:
            invoice["charge"] = None
        list_invoice_mock.return_value = stripe_invoice_response
        plan = PlanName.CODECOV_PRO_YEARLY.value
        stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
        stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
        customer_id = "cus_HF6p8Zx7JdRS7A"
        owner = OwnerFactory(
            stripe_subscription_id=stripe_subscription_id,
            plan=plan,
            plan_activated_users=[4, 6, 3],
            plan_user_count=9,
            stripe_customer_id=customer_id,
        )
        subscription_params = {
            "schedule_id": stripe_schedule_id,
            "start_date": 1489799420,
            "end_date": 1492477820,
            "quantity": 10,
            "name": plan,
            "id": 215,
            "plan": {
                "new_plan": "plan_H6P3KZXwmAbqPS",
                "new_quantity": 7,
                "subscription_id": "sub_123",
                "interval": "year",
            },
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
        retrieve_customer_mock.return_value = {
            "id": "cus_HF6p8Zx7JdRS7A",
            "metadata": {"autorefunds_remaining": "1"},
        }
&gt;       self.stripe.delete_subscription(owner)

services/tests/test_billing.py:596: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;services.billing.StripeService object at 0x7f7e80beb8c0&gt;
owner = &lt;Owner: Owner&lt;github/bgallagher&gt;&gt;
current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
subscription_plan_interval = 'year', autorefunds_remaining = 1

    def cancel_and_refund(
        self,
        owner,
        current_subscription_datetime,
        subscription_plan_interval,
        autorefunds_remaining,
    ):
        stripe.Subscription.cancel(owner.stripe_subscription_id)
    
        start_of_last_period = current_subscription_datetime - relativedelta(months=1)
        invoice_grace_period_start = current_subscription_datetime - relativedelta(
            days=1
        )
    
        if subscription_plan_interval == "year":
            start_of_last_period = current_subscription_datetime - relativedelta(
                years=1
            )
            invoice_grace_period_start = current_subscription_datetime - relativedelta(
                days=3
            )
    
        invoices_list = stripe.Invoice.list(
            subscription=owner.stripe_subscription_id,
            status="paid",
            created={
                "created.gte": int(start_of_last_period.timestamp()),
                "created.lt": int(current_subscription_datetime.timestamp()),
            },
        )
    
        # we only want to refund the invoices for the latest, current period
        recently_paid_invoices_list = [
            invoice
            for invoice in invoices_list["data"]
            if invoice["status_transitions"]["paid_at"] is not None
&gt;           and invoice["status_transitions"]["paid_at"] &gt;= invoice_grace_period_start
        ]
E       TypeError: '&gt;=' not supported between instances of 'int' and 'FakeDatetime'

services/billing.py:188: TypeError
services/tests/test_billing.py::StripeServiceTests::test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan
Stack Traces | 0.041s run time
self = &lt;services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan&gt;
retrieve_customer_mock = &lt;MagicMock name='retrieve' id='140181298008176'&gt;
schedule_release_mock = &lt;MagicMock name='release' id='140181297547360'&gt;
retrieve_subscription_mock = &lt;MagicMock name='retrieve' id='140181298010576'&gt;
cancel_sub_mock = &lt;MagicMock name='cancel' id='140181299252208'&gt;
list_invoice_mock = &lt;MagicMock name='list' id='140181299264304'&gt;
create_refund_mock = &lt;MagicMock name='create' id='140181301541776'&gt;
modify_customer_mock = &lt;MagicMock name='modify' id='140181297899200'&gt;
modify_sub_mock = &lt;MagicMock name='modify' id='140181298006352'&gt;

    @freeze_time("2017-03-18T00:00:00")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    @patch("services.billing.stripe.Customer.retrieve")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan(
        self,
        retrieve_customer_mock,
        schedule_release_mock,
        retrieve_subscription_mock,
        cancel_sub_mock,
        list_invoice_mock,
        create_refund_mock,
        modify_customer_mock,
        modify_sub_mock,
    ):
        with open("..../tests/samples/stripe_invoice.json") as f:
            stripe_invoice_response = json.load(f)
        list_invoice_mock.return_value = stripe_invoice_response
        plan = PlanName.CODECOV_PRO_YEARLY.value
        stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
        stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
        customer_id = "cus_HF6p8Zx7JdRS7A"
        owner = OwnerFactory(
            stripe_subscription_id=stripe_subscription_id,
            plan=plan,
            plan_activated_users=[4, 6, 3],
            plan_user_count=9,
            stripe_customer_id=customer_id,
        )
        subscription_params = {
            "schedule_id": stripe_schedule_id,
            "start_date": 1489799420,
            "end_date": 1492477820,
            "quantity": 10,
            "name": plan,
            "id": 215,
            "plan": {
                "new_plan": "plan_H6P3KZXwmAbqPS",
                "new_quantity": 7,
                "subscription_id": "sub_123",
                "interval": "month",
            },
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
        retrieve_customer_mock.return_value = {
            "id": "cus_HF6p8Zx7JdRS7A",
            "metadata": {},
        }
&gt;       self.stripe.delete_subscription(owner)

services/tests/test_billing.py:444: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;services.billing.StripeService object at 0x7f7e80772c60&gt;
owner = &lt;Owner: Owner&lt;github/travis02&gt;&gt;
current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
subscription_plan_interval = 'year', autorefunds_remaining = 2

    def cancel_and_refund(
        self,
        owner,
        current_subscription_datetime,
        subscription_plan_interval,
        autorefunds_remaining,
    ):
        stripe.Subscription.cancel(owner.stripe_subscription_id)
    
        start_of_last_period = current_subscription_datetime - relativedelta(months=1)
        invoice_grace_period_start = current_subscription_datetime - relativedelta(
            days=1
        )
    
        if subscription_plan_interval == "year":
            start_of_last_period = current_subscription_datetime - relativedelta(
                years=1
            )
            invoice_grace_period_start = current_subscription_datetime - relativedelta(
                days=3
            )
    
        invoices_list = stripe.Invoice.list(
            subscription=owner.stripe_subscription_id,
            status="paid",
            created={
                "created.gte": int(start_of_last_period.timestamp()),
                "created.lt": int(current_subscription_datetime.timestamp()),
            },
        )
    
        # we only want to refund the invoices for the latest, current period
        recently_paid_invoices_list = [
            invoice
            for invoice in invoices_list["data"]
            if invoice["status_transitions"]["paid_at"] is not None
&gt;           and invoice["status_transitions"]["paid_at"] &gt;= invoice_grace_period_start
        ]
E       TypeError: '&gt;=' not supported between instances of 'int' and 'FakeDatetime'

services/billing.py:188: TypeError
services/tests/test_billing.py::StripeServiceTests::test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan
Stack Traces | 0.045s run time
self = &lt;services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan&gt;
schedule_release_mock = &lt;MagicMock name='release' id='140181299537600'&gt;
retrieve_subscription_mock = &lt;MagicMock name='retrieve' id='140181298255328'&gt;
cancel_sub_mock = &lt;MagicMock name='cancel' id='140181296926496'&gt;
list_invoice_mock = &lt;MagicMock name='list' id='140181297642432'&gt;
create_refund_mock = &lt;MagicMock name='create' id='140181296934128'&gt;
modify_customer_mock = &lt;MagicMock name='modify' id='140181296938112'&gt;
modify_sub_mock = &lt;MagicMock name='modify' id='140181296934224'&gt;
retrieve_customer_mock = &lt;MagicMock name='retrieve' id='140181297060592'&gt;

    @freeze_time("2017-03-19T00:00:00")
    @patch("services.billing.stripe.Customer.retrieve")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan(
        self,
        schedule_release_mock,
        retrieve_subscription_mock,
        cancel_sub_mock,
        list_invoice_mock,
        create_refund_mock,
        modify_customer_mock,
        modify_sub_mock,
        retrieve_customer_mock,
    ):
        with open("..../tests/samples/stripe_invoice.json") as f:
            stripe_invoice_response = json.load(f)
        list_invoice_mock.return_value = stripe_invoice_response
        plan = PlanName.CODECOV_PRO_YEARLY.value
        stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
        stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
        customer_id = "cus_HF6p8Zx7JdRS7A"
        owner = OwnerFactory(
            stripe_subscription_id=stripe_subscription_id,
            plan=plan,
            plan_activated_users=[4, 6, 3],
            plan_user_count=9,
            stripe_customer_id=customer_id,
        )
        subscription_params = {
            "schedule_id": stripe_schedule_id,
            "start_date": 1489799420,
            "end_date": 1492477820,
            "quantity": 10,
            "name": plan,
            "id": 215,
            "plan": {
                "new_plan": "plan_H6P3KZXwmAbqPS",
                "new_quantity": 7,
                "subscription_id": "sub_123",
                "interval": "year",
            },
        }
    
        retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
        retrieve_customer_mock.return_value = {
            "id": "cus_HF6p8Zx7JdRS7A",
            "metadata": {"autorefunds_remaining": "1"},
        }
&gt;       self.stripe.delete_subscription(owner)

services/tests/test_billing.py:519: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;services.billing.StripeService object at 0x7f7e808e51c0&gt;
owner = &lt;Owner: Owner&lt;github/zbutler&gt;&gt;
current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
subscription_plan_interval = 'year', autorefunds_remaining = 1

    def cancel_and_refund(
        self,
        owner,
        current_subscription_datetime,
        subscription_plan_interval,
        autorefunds_remaining,
    ):
        stripe.Subscription.cancel(owner.stripe_subscription_id)
    
        start_of_last_period = current_subscription_datetime - relativedelta(months=1)
        invoice_grace_period_start = current_subscription_datetime - relativedelta(
            days=1
        )
    
        if subscription_plan_interval == "year":
            start_of_last_period = current_subscription_datetime - relativedelta(
                years=1
            )
            invoice_grace_period_start = current_subscription_datetime - relativedelta(
                days=3
            )
    
        invoices_list = stripe.Invoice.list(
            subscription=owner.stripe_subscription_id,
            status="paid",
            created={
                "created.gte": int(start_of_last_period.timestamp()),
                "created.lt": int(current_subscription_datetime.timestamp()),
            },
        )
    
        # we only want to refund the invoices for the latest, current period
        recently_paid_invoices_list = [
            invoice
            for invoice in invoices_list["data"]
            if invoice["status_transitions"]["paid_at"] is not None
&gt;           and invoice["status_transitions"]["paid_at"] &gt;= invoice_grace_period_start
        ]
E       TypeError: '&gt;=' not supported between instances of 'int' and 'FakeDatetime'

services/billing.py:188: TypeError

To view individual test run time comparison to the main branch, go to the Test Analytics Dashboard

Copy link

codecov-public-qa bot commented Oct 24, 2024

Test Failures Detected: Due to failing tests, we cannot provide coverage reports at this time.

❌ Failed Test Results:

Completed 2647 tests with 3 failed, 2638 passed and 6 skipped.

View the full list of failed tests

pytest

  • Class name: services.tests.test_billing.StripeServiceTests
    Test name: test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund

    self = <services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund>
    schedule_release_mock = <MagicMock name='release' id='140181302589056'>
    retrieve_subscription_mock = <MagicMock name='retrieve' id='140181299640608'>
    cancel_sub_mock = <MagicMock name='cancel' id='140181301587424'>
    list_invoice_mock = <MagicMock name='list' id='140181299539904'>
    create_refund_mock = <MagicMock name='create' id='140181301532800'>
    modify_customer_mock = <MagicMock name='modify' id='140181297816896'>
    modify_sub_mock = <MagicMock name='modify' id='140181297814736'>
    retrieve_customer_mock = <MagicMock name='retrieve' id='140181297813056'>

    @freeze_time("2017-03-19T00:00:00")
    @patch("services.billing.stripe.Customer.retrieve")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_immediately_with_grace_year_but_no_invoices_to_refund(
    self,
    schedule_release_mock,
    retrieve_subscription_mock,
    cancel_sub_mock,
    list_invoice_mock,
    create_refund_mock,
    modify_customer_mock,
    modify_sub_mock,
    retrieve_customer_mock,
    ):
    with open("..../tests/samples/stripe_invoice.json") as f:
    stripe_invoice_response = json.load(f)
    for invoice in stripe_invoice_response["data"]:
    invoice["charge"] = None
    list_invoice_mock.return_value = stripe_invoice_response
    plan = PlanName.CODECOV_PRO_YEARLY.value
    stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
    stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
    customer_id = "cus_HF6p8Zx7JdRS7A"
    owner = OwnerFactory(
    stripe_subscription_id=stripe_subscription_id,
    plan=plan,
    plan_activated_users=[4, 6, 3],
    plan_user_count=9,
    stripe_customer_id=customer_id,
    )
    subscription_params = {
    "schedule_id": stripe_schedule_id,
    "start_date": 1489799420,
    "end_date": 1492477820,
    "quantity": 10,
    "name": plan,
    "id": 215,
    "plan": {
    "new_plan": "plan_H6P3KZXwmAbqPS",
    "new_quantity": 7,
    "subscription_id": "sub_123",
    "interval": "year",
    },
    }

    retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    retrieve_customer_mock.return_value = {
    "id": "cus_HF6p8Zx7JdRS7A",
    "metadata": {"autorefunds_remaining": "1"},
    }
    > self.stripe.delete_subscription(owner)

    services/tests/test_billing.py:596:
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
    services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    self = <services.billing.StripeService object at 0x7f7e80beb8c0>
    owner = <Owner: Owner<github/bgallagher>>
    current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
    subscription_plan_interval = 'year', autorefunds_remaining = 1

    def cancel_and_refund(
    self,
    owner,
    current_subscription_datetime,
    subscription_plan_interval,
    autorefunds_remaining,
    ):
    stripe.Subscription.cancel(owner.stripe_subscription_id)

    start_of_last_period = current_subscription_datetime - relativedelta(months=1)
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=1
    )

    if subscription_plan_interval == "year":
    start_of_last_period = current_subscription_datetime - relativedelta(
    years=1
    )
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=3
    )

    invoices_list = stripe.Invoice.list(
    subscription=owner.stripe_subscription_id,
    status="paid",
    created={
    "created.gte": int(start_of_last_period.timestamp()),
    "created.lt": int(current_subscription_datetime.timestamp()),
    },
    )

    # we only want to refund the invoices for the latest, current period
    recently_paid_invoices_list = [
    invoice
    for invoice in invoices_list["data"]
    if invoice["status_transitions"]["paid_at"] is not None
    > and invoice["status_transitions"]["paid_at"] >= invoice_grace_period_start
    ]
    E TypeError: '>=' not supported between instances of 'int' and 'FakeDatetime'

    services/billing.py:188: TypeError
  • Class name: services.tests.test_billing.StripeServiceTests
    Test name: test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan

    self = <services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan>
    retrieve_customer_mock = <MagicMock name='retrieve' id='140181298008176'>
    schedule_release_mock = <MagicMock name='release' id='140181297547360'>
    retrieve_subscription_mock = <MagicMock name='retrieve' id='140181298010576'>
    cancel_sub_mock = <MagicMock name='cancel' id='140181299252208'>
    list_invoice_mock = <MagicMock name='list' id='140181299264304'>
    create_refund_mock = <MagicMock name='create' id='140181301541776'>
    modify_customer_mock = <MagicMock name='modify' id='140181297899200'>
    modify_sub_mock = <MagicMock name='modify' id='140181298006352'>

    @freeze_time("2017-03-18T00:00:00")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    @patch("services.billing.stripe.Customer.retrieve")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_month_refund_if_valid_plan(
    self,
    retrieve_customer_mock,
    schedule_release_mock,
    retrieve_subscription_mock,
    cancel_sub_mock,
    list_invoice_mock,
    create_refund_mock,
    modify_customer_mock,
    modify_sub_mock,
    ):
    with open("..../tests/samples/stripe_invoice.json") as f:
    stripe_invoice_response = json.load(f)
    list_invoice_mock.return_value = stripe_invoice_response
    plan = PlanName.CODECOV_PRO_YEARLY.value
    stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
    stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
    customer_id = "cus_HF6p8Zx7JdRS7A"
    owner = OwnerFactory(
    stripe_subscription_id=stripe_subscription_id,
    plan=plan,
    plan_activated_users=[4, 6, 3],
    plan_user_count=9,
    stripe_customer_id=customer_id,
    )
    subscription_params = {
    "schedule_id": stripe_schedule_id,
    "start_date": 1489799420,
    "end_date": 1492477820,
    "quantity": 10,
    "name": plan,
    "id": 215,
    "plan": {
    "new_plan": "plan_H6P3KZXwmAbqPS",
    "new_quantity": 7,
    "subscription_id": "sub_123",
    "interval": "month",
    },
    }

    retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    retrieve_customer_mock.return_value = {
    "id": "cus_HF6p8Zx7JdRS7A",
    "metadata": {},
    }
    > self.stripe.delete_subscription(owner)

    services/tests/test_billing.py:444:
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
    services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    self = <services.billing.StripeService object at 0x7f7e80772c60>
    owner = <Owner: Owner<github/travis02>>
    current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
    subscription_plan_interval = 'year', autorefunds_remaining = 2

    def cancel_and_refund(
    self,
    owner,
    current_subscription_datetime,
    subscription_plan_interval,
    autorefunds_remaining,
    ):
    stripe.Subscription.cancel(owner.stripe_subscription_id)

    start_of_last_period = current_subscription_datetime - relativedelta(months=1)
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=1
    )

    if subscription_plan_interval == "year":
    start_of_last_period = current_subscription_datetime - relativedelta(
    years=1
    )
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=3
    )

    invoices_list = stripe.Invoice.list(
    subscription=owner.stripe_subscription_id,
    status="paid",
    created={
    "created.gte": int(start_of_last_period.timestamp()),
    "created.lt": int(current_subscription_datetime.timestamp()),
    },
    )

    # we only want to refund the invoices for the latest, current period
    recently_paid_invoices_list = [
    invoice
    for invoice in invoices_list["data"]
    if invoice["status_transitions"]["paid_at"] is not None
    > and invoice["status_transitions"]["paid_at"] >= invoice_grace_period_start
    ]
    E TypeError: '>=' not supported between instances of 'int' and 'FakeDatetime'

    services/billing.py:188: TypeError
  • Class name: services.tests.test_billing.StripeServiceTests
    Test name: test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan

    self = <services.tests.test_billing.StripeServiceTests testMethod=test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan>
    schedule_release_mock = <MagicMock name='release' id='140181299537600'>
    retrieve_subscription_mock = <MagicMock name='retrieve' id='140181298255328'>
    cancel_sub_mock = <MagicMock name='cancel' id='140181296926496'>
    list_invoice_mock = <MagicMock name='list' id='140181297642432'>
    create_refund_mock = <MagicMock name='create' id='140181296934128'>
    modify_customer_mock = <MagicMock name='modify' id='140181296938112'>
    modify_sub_mock = <MagicMock name='modify' id='140181296934224'>
    retrieve_customer_mock = <MagicMock name='retrieve' id='140181297060592'>

    @freeze_time("2017-03-19T00:00:00")
    @patch("services.billing.stripe.Customer.retrieve")
    @patch("services.billing.stripe.Subscription.modify")
    @patch("services.billing.stripe.Customer.modify")
    @patch("services.billing.stripe.Refund.create")
    @patch("services.billing.stripe.Invoice.list")
    @patch("services.billing.stripe.Subscription.cancel")
    @patch("services.billing.stripe.Subscription.retrieve")
    @patch("services.billing.stripe.SubscriptionSchedule.release")
    def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_with_grace_year_refund_if_valid_plan(
    self,
    schedule_release_mock,
    retrieve_subscription_mock,
    cancel_sub_mock,
    list_invoice_mock,
    create_refund_mock,
    modify_customer_mock,
    modify_sub_mock,
    retrieve_customer_mock,
    ):
    with open("..../tests/samples/stripe_invoice.json") as f:
    stripe_invoice_response = json.load(f)
    list_invoice_mock.return_value = stripe_invoice_response
    plan = PlanName.CODECOV_PRO_YEARLY.value
    stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne"
    stripe_schedule_id = "sub_sched_sch1K77Y5GlVGuVgOrkJrLjRnne"
    customer_id = "cus_HF6p8Zx7JdRS7A"
    owner = OwnerFactory(
    stripe_subscription_id=stripe_subscription_id,
    plan=plan,
    plan_activated_users=[4, 6, 3],
    plan_user_count=9,
    stripe_customer_id=customer_id,
    )
    subscription_params = {
    "schedule_id": stripe_schedule_id,
    "start_date": 1489799420,
    "end_date": 1492477820,
    "quantity": 10,
    "name": plan,
    "id": 215,
    "plan": {
    "new_plan": "plan_H6P3KZXwmAbqPS",
    "new_quantity": 7,
    "subscription_id": "sub_123",
    "interval": "year",
    },
    }

    retrieve_subscription_mock.return_value = MockSubscription(subscription_params)
    retrieve_customer_mock.return_value = {
    "id": "cus_HF6p8Zx7JdRS7A",
    "metadata": {"autorefunds_remaining": "1"},
    }
    > self.stripe.delete_subscription(owner)

    services/tests/test_billing.py:519:
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
    services/billing.py:33: in catch_and_raise
    return method(*args, **kwargs)
    services/billing.py:277: in delete_subscription
    return self.cancel_and_refund(
    _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    self = <services.billing.StripeService object at 0x7f7e808e51c0>
    owner = <Owner: Owner<github/zbutler>>
    current_subscription_datetime = FakeDatetime(2017, 3, 18, 1, 10, 20, tzinfo=datetime.timezone.utc)
    subscription_plan_interval = 'year', autorefunds_remaining = 1

    def cancel_and_refund(
    self,
    owner,
    current_subscription_datetime,
    subscription_plan_interval,
    autorefunds_remaining,
    ):
    stripe.Subscription.cancel(owner.stripe_subscription_id)

    start_of_last_period = current_subscription_datetime - relativedelta(months=1)
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=1
    )

    if subscription_plan_interval == "year":
    start_of_last_period = current_subscription_datetime - relativedelta(
    years=1
    )
    invoice_grace_period_start = current_subscription_datetime - relativedelta(
    days=3
    )

    invoices_list = stripe.Invoice.list(
    subscription=owner.stripe_subscription_id,
    status="paid",
    created={
    "created.gte": int(start_of_last_period.timestamp()),
    "created.lt": int(current_subscription_datetime.timestamp()),
    },
    )

    # we only want to refund the invoices for the latest, current period
    recently_paid_invoices_list = [
    invoice
    for invoice in invoices_list["data"]
    if invoice["status_transitions"]["paid_at"] is not None
    > and invoice["status_transitions"]["paid_at"] >= invoice_grace_period_start
    ]
    E TypeError: '>=' not supported between instances of 'int' and 'FakeDatetime'

    services/billing.py:188: TypeError

Comment on lines 181 to 185
customer = stripe.Customer.retrieve(owner.stripe_customer_id)
# we are giving customers 2 autorefund instances
autorefunds_remaining = int(
customer["metadata"].get("autorefunds_remaining", "2")
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want this logic to be inside the if within_refund_grace_period block so it's only called when they are within the cancellation window

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Any ideas about how to prevent another level of if statements or in your opinion, does that seem fine? Do we have a general rule of thumb for how many levels we think is acceptable?

cancel_at_period_end=True,
proration_behavior="none",
# we give an auto-refund grace period of 24 hours for a monthly subscription or 72 hours for a yearly subscription
current_subscription_datetime = datetime.fromtimestamp(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, are the timezones for both datetime.now() and datetime.fromtimestamp() the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great question. I just checked and both are in machine local so since we only care about the difference, we're good.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great! Thanks for checking

Copy link
Contributor Author

@calvin-codecov calvin-codecov Oct 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm converting these to utc now actually because I realized depending on what the local time of the machine is, it could change the date. Like if february 29th was converted to march 1st due to timezone difference, subtracting 1 month would give february 1st instead of january 29th

if within_refund_grace_period and autorefunds_remaining > 0:
stripe.Subscription.cancel(owner.stripe_subscription_id)

invoices_list = stripe.Invoice.list(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like you can add a created parameter to this call too and maybe shorten the amount of results to filter through as well. Not sure how much of an optimization... maybe to the point of not needing this stuff:

and invoice_created_datetime < current_subscription_datetime
and invoice_created_datetime >= start_of_last_period

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would allow you to this variable above and condense this for loop as well

start_of_last_period = (
current_subscription_datetime - relativedelta(months=1)
if subscription_plan_interval == "month"
else current_subscription_datetime - relativedelta(years=1)
)

balance=0,
metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)},
)
log.info(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any other log info that might be useful here? Maybe customer id and sub id off the top of my head, we can add them with the extra=dict() param

Comment on lines 151 to 158
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
def cancel_and_refund(
self,
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period
def cancel_and_refund(
self,
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
):
def cancel_and_refund(
self,
owner,
current_subscription_datetime,
subscription_plan_interval,
autorefunds_remaining,
):
# cancels a Stripe customer subscription immediately and attempts to refund their payments for the current period

Copy link
Contributor

@nora-codecov nora-codecov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left 1 suggested change - the comment should be inside the func, other than that I think it's ready to go! 🚀


if created_refund:
# update the customer's balance back to 0 in accordance to
# https://support.stripe.com/questions/refunding-credit-balance-to-customer-after-subscription-downgrade-or-cancellation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ty for the documentation!!

autorefunds_remaining=autorefunds_remaining,
),
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great logs!

Copy link
Contributor

@ajay-sentry ajay-sentry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nice!

@calvin-codecov calvin-codecov added this pull request to the merge queue Oct 29, 2024
@calvin-codecov calvin-codecov removed this pull request from the merge queue due to a manual request Oct 29, 2024
@calvin-codecov calvin-codecov added this pull request to the merge queue Oct 29, 2024
Merged via the queue into main with commit f484524 Oct 29, 2024
18 of 19 checks passed
@calvin-codecov calvin-codecov deleted the cy/cancellation_grace branch October 29, 2024 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[API] Update Cancelation logic checking for when renewal/creation occurred
3 participants