diff --git a/services/billing.py b/services/billing.py index 751151bd93..9d804e9174 100644 --- a/services/billing.py +++ b/services/billing.py @@ -1,8 +1,10 @@ import logging import re from abc import ABC, abstractmethod +from datetime import datetime, timezone import stripe +from dateutil.relativedelta import relativedelta from django.conf import settings from billing.constants import REMOVED_INVOICE_STATUSES @@ -146,6 +148,87 @@ def list_filtered_invoices(self, owner: Owner, limit=10): ) return list(invoices_filtered_by_status_and_total) + 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 + 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 PAID recently for the latest, current period. "invoices_list" gives us any invoice + # created over the last month/year based on what period length they are on but the customer could have possibly + # switched from monthly to yearly recently. + 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"] + >= int(invoice_grace_period_start.timestamp()) + ] + + created_refund = False + # there could be multiple invoices that need to be refunded such as if the user increased seats within the grace period + for invoice in recently_paid_invoices_list: + # refund if the invoice has a charge and it has been fully paid + if invoice["charge"] is not None and invoice["amount_remaining"] == 0: + stripe.Refund.create(invoice["charge"]) + created_refund = True + + 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 + stripe.Customer.modify( + owner.stripe_customer_id, + balance=0, + metadata={"autorefunds_remaining": str(autorefunds_remaining - 1)}, + ) + log.info( + "Grace period cancelled a subscription and autorefunded associated invoices", + extra=dict( + owner_id=owner.ownerid, + user_id=self.requesting_user.ownerid, + subscription_id=owner.stripe_subscription_id, + customer_id=owner.stripe_customer_id, + autorefunds_remaining=autorefunds_remaining - 1, + ), + ) + else: + log.info( + "Grace period cancelled a subscription but did not find any appropriate invoices to autorefund", + extra=dict( + owner_id=owner.ownerid, + user_id=self.requesting_user.ownerid, + subscription_id=owner.stripe_subscription_id, + customer_id=owner.stripe_customer_id, + autorefunds_remaining=autorefunds_remaining, + ), + ) + @_log_stripe_error def delete_subscription(self, owner: Owner): subscription = stripe.Subscription.retrieve(owner.stripe_subscription_id) @@ -155,6 +238,7 @@ def delete_subscription(self, owner: Owner): f"Downgrade to basic plan from user plan for owner {owner.ownerid} by user #{self.requesting_user.ownerid}", extra=dict(ownerid=owner.ownerid), ) + if subscription_schedule_id: log.info( f"Releasing subscription from schedule for owner {owner.ownerid} by user #{self.requesting_user.ownerid}", @@ -162,6 +246,44 @@ def delete_subscription(self, owner: Owner): ) stripe.SubscriptionSchedule.release(subscription_schedule_id) + # 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( + subscription["current_period_start"], tz=timezone.utc + ) + difference_from_now = datetime.now(timezone.utc) - current_subscription_datetime + + subscription_plan_interval = getattr( + getattr(subscription, "plan", None), "interval", None + ) + within_refund_grace_period = ( + subscription_plan_interval == "month" and difference_from_now.days < 1 + ) or (subscription_plan_interval == "year" and difference_from_now.days < 3) + + if within_refund_grace_period: + customer = stripe.Customer.retrieve(owner.stripe_customer_id) + # we are currently allowing customers 2 autorefund instances + autorefunds_remaining = int( + customer["metadata"].get("autorefunds_remaining", "2") + ) + log.info( + "Deleting subscription with attempted immediate cancellation with autorefund within grace period", + extra=dict( + owner_id=owner.ownerid, + user_id=self.requesting_user.ownerid, + subscription_id=owner.stripe_subscription_id, + customer_id=owner.stripe_customer_id, + autorefunds_remaining=autorefunds_remaining, + ), + ) + if autorefunds_remaining > 0: + return self.cancel_and_refund( + owner, + current_subscription_datetime, + subscription_plan_interval, + autorefunds_remaining, + ) + + # schedule a cancellation at the end of the paid period with no refund stripe.Subscription.modify( owner.stripe_subscription_id, cancel_at_period_end=True, diff --git a/services/tests/samples/stripe_invoice.json b/services/tests/samples/stripe_invoice.json index 7e99ac1d6a..2947637136 100644 --- a/services/tests/samples/stripe_invoice.json +++ b/services/tests/samples/stripe_invoice.json @@ -120,7 +120,7 @@ "total_tax_amounts": [], "webhooks_delivered_at": 1489789437 }, - { + { "id": "in_19yTU92eZvKYlo2C7uDjvu69", "object": "invoice", "account_country": "US", diff --git a/services/tests/test_billing.py b/services/tests/test_billing.py index f8ddbbab0c..8f50d9196f 100644 --- a/services/tests/test_billing.py +++ b/services/tests/test_billing.py @@ -4,6 +4,7 @@ import requests from django.conf import settings from django.test import TestCase +from freezegun import freeze_time from shared.django_apps.core.tests.factories import OwnerFactory from stripe import InvalidRequestError @@ -129,17 +130,33 @@ ] +class MockSubscriptionPlan(object): + def __init__(self, params): + self.id = params["new_plan"] + self.interval = "year" + + class MockSubscription(object): def __init__(self, subscription_params): self.schedule = subscription_params["schedule_id"] self.current_period_start = subscription_params["start_date"] self.current_period_end = subscription_params["end_date"] + self.plan = ( + MockSubscriptionPlan(subscription_params["plan"]) + if subscription_params.get("plan") is not None + else None + ) self.items = { "data": [ { "quantity": subscription_params["quantity"], "id": subscription_params["id"], - "plan": {"id": subscription_params["name"]}, + "plan": { + "id": subscription_params["name"], + "interval": subscription_params.get("plan", {}).get( + "interval", "month" + ), + }, } ] } @@ -270,30 +287,39 @@ def test_list_filtered_invoices_returns_emptylist_if_stripe_customer_id_is_None( invoice_list_mock.assert_not_called() assert invoices == [] + @patch("services.billing.stripe.Customer.retrieve") @patch("services.billing.stripe.Subscription.retrieve") @patch("services.billing.stripe.Subscription.modify") def test_delete_subscription_without_schedule_modifies_subscription_to_delete_at_end_of_billing_cycle_if_valid_plan( - self, modify_mock, retrieve_subscription_mock + self, modify_mock, retrieve_subscription_mock, retrieve_customer_mock ): plan = PlanName.CODECOV_PRO_YEARLY.value stripe_subscription_id = "sub_1K77Y5GlVGuVgOrkJrLjRnne" stripe_schedule_id = None + 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": 1639628096, - "end_date": 1644107871, + "start_date": 1489799420, + "end_date": 1492477820, "quantity": 10, "name": plan, "id": 215, } retrieve_subscription_mock.return_value = MockSubscription(subscription_params) + retrieve_customer_mock.return_value = { + "id": "cus_123456789", + "email": "test@example.com", + "name": "Test User", + "metadata": {}, + } self.stripe.delete_subscription(owner) modify_mock.assert_called_once_with( stripe_subscription_id, @@ -306,31 +332,47 @@ def test_delete_subscription_without_schedule_modifies_subscription_to_delete_at assert owner.plan_activated_users == [4, 6, 3] assert owner.plan_user_count == 9 + @freeze_time("2017-03-22T00:00:00") + @patch("services.billing.stripe.Customer.retrieve") + @patch("services.billing.stripe.Refund.create") @patch("services.billing.stripe.Subscription.modify") @patch("services.billing.stripe.Subscription.retrieve") @patch("services.billing.stripe.SubscriptionSchedule.release") def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscription_at_end_of_billing_cycle_if_valid_plan( - self, schedule_release_mock, retrieve_subscription_mock, modify_mock + self, + schedule_release_mock, + retrieve_subscription_mock, + modify_mock, + create_refund_mock, + retrieve_customer_mock, ): 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": 1639628096, - "end_date": 1644107871, + "start_date": 1489799420, + "end_date": 1492477820, "quantity": 10, "name": plan, "id": 215, } retrieve_subscription_mock.return_value = MockSubscription(subscription_params) + retrieve_customer_mock.return_value = { + "id": "cus_123456789", + "email": "test@example.com", + "name": "Test User", + "metadata": {}, + } self.stripe.delete_subscription(owner) schedule_release_mock.assert_called_once_with(stripe_schedule_id) modify_mock.assert_called_once_with( @@ -338,6 +380,304 @@ def test_delete_subscription_with_schedule_releases_schedule_and_cancels_subscri cancel_at_period_end=True, proration_behavior="none", ) + create_refund_mock.assert_not_called() + owner.refresh_from_db() + assert owner.stripe_subscription_id == stripe_subscription_id + assert owner.plan == plan + assert owner.plan_activated_users == [4, 6, 3] + assert owner.plan_user_count == 9 + + @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("./services/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) + schedule_release_mock.assert_called_once_with(stripe_schedule_id) + retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id) + cancel_sub_mock.assert_called_once_with(stripe_subscription_id) + list_invoice_mock.assert_called_once_with( + subscription=stripe_subscription_id, + status="paid", + created={"created.gte": 1458263420, "created.lt": 1489799420}, + ) + self.assertEqual(create_refund_mock.call_count, 2) + modify_customer_mock.assert_called_once_with( + owner.stripe_customer_id, balance=0, metadata={"autorefunds_remaining": "1"} + ) + modify_sub_mock.assert_not_called() + + owner.refresh_from_db() + assert owner.stripe_subscription_id == stripe_subscription_id + assert owner.plan == plan + assert owner.plan_activated_users == [4, 6, 3] + assert owner.plan_user_count == 9 + + @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("./services/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) + schedule_release_mock.assert_called_once_with(stripe_schedule_id) + retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id) + cancel_sub_mock.assert_called_once_with(stripe_subscription_id) + list_invoice_mock.assert_called_once_with( + subscription=stripe_subscription_id, + status="paid", + created={"created.gte": 1458263420, "created.lt": 1489799420}, + ) + self.assertEqual(create_refund_mock.call_count, 2) + modify_customer_mock.assert_called_once_with( + owner.stripe_customer_id, balance=0, metadata={"autorefunds_remaining": "0"} + ) + modify_sub_mock.assert_not_called() + + owner.refresh_from_db() + assert owner.stripe_subscription_id == stripe_subscription_id + assert owner.plan == plan + assert owner.plan_activated_users == [4, 6, 3] + assert owner.plan_user_count == 9 + + @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("./services/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) + schedule_release_mock.assert_called_once_with(stripe_schedule_id) + retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id) + cancel_sub_mock.assert_called_once_with(stripe_subscription_id) + list_invoice_mock.assert_called_once_with( + subscription=stripe_subscription_id, + status="paid", + created={"created.gte": 1458263420, "created.lt": 1489799420}, + ) + create_refund_mock.assert_not_called() + modify_customer_mock.assert_not_called() + modify_sub_mock.assert_not_called() + + owner.refresh_from_db() + assert owner.stripe_subscription_id == stripe_subscription_id + assert owner.plan == plan + assert owner.plan_activated_users == [4, 6, 3] + assert owner.plan_user_count == 9 + + @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_at_end_of_billing_cycle_as_no_more_autorefunds_available( + 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("./services/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": "0"}, + } + self.stripe.delete_subscription(owner) + schedule_release_mock.assert_called_once_with(stripe_schedule_id) + retrieve_customer_mock.assert_called_once_with(owner.stripe_customer_id) + cancel_sub_mock.assert_not_called() + create_refund_mock.assert_not_called() + modify_customer_mock.assert_not_called() + modify_sub_mock.assert_called_once_with( + stripe_subscription_id, + cancel_at_period_end=True, + proration_behavior="none", + ) + owner.refresh_from_db() assert owner.stripe_subscription_id == stripe_subscription_id assert owner.plan == plan