From 62d63cafd956d6c3faf06d9872c5be49be854f38 Mon Sep 17 00:00:00 2001 From: Matthew Somerville Date: Wed, 2 Oct 2024 14:56:43 +0100 Subject: [PATCH] Add script to schedule in price changes. --- .../commands/schedule_price_change.py | 95 +++++++++++ subscriptions/tests.py | 157 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 subscriptions/management/commands/schedule_price_change.py diff --git a/subscriptions/management/commands/schedule_price_change.py b/subscriptions/management/commands/schedule_price_change.py new file mode 100644 index 0000000..4c8eba8 --- /dev/null +++ b/subscriptions/management/commands/schedule_price_change.py @@ -0,0 +1,95 @@ +from django.conf import settings +from django.core.management.base import BaseCommand +import stripe + +from subscriptions.models import Subscription + +stripe.api_key = settings.STRIPE_SECRET_KEY +stripe.api_version = settings.STRIPE_API_VERSION + + +class Command(BaseCommand): + help = "Update subscriptions on one Stripe Price to a different Price" + + def add_arguments(self, parser): + prices = stripe.Price.list(limit=100) + price_ids = [price.id for price in prices.data if price.product.name.startswith('MapIt')] + parser.add_argument('--old-price', choices=price_ids, required=True) + parser.add_argument('--new-price', choices=price_ids, required=True) + parser.add_argument('--commit', action='store_true') + + def new_schedule(self, subscription, new_price): + schedule = stripe.SubscriptionSchedule.create(from_subscription=subscription.id) + phases = [ + { + 'items': [{'price': schedule.phases[0]['items'][0].price}], + 'start_date': schedule.phases[0].start_date, + 'iterations': 2, + 'proration_behavior': 'none', + 'default_tax_rates': [settings.STRIPE_TAX_RATE], + }, + { + 'items': [{'price': new_price}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [settings.STRIPE_TAX_RATE], + }, + ] + # Maintain current discount, if any + if schedule.phases[0].discounts and schedule.phases[0].discounts[0].coupon: + phases[0]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}] + phases[1]['discounts'] = [{'coupon': schedule.phases[0].discounts[0].coupon}] + stripe.SubscriptionSchedule.modify(schedule.id, phases=phases) + + def handle(self, *args, **options): + old_price = options['old_price'] + new_price = options['new_price'] + + for sub_obj in Subscription.objects.all(): + subscription = stripe.Subscription.retrieve(sub_obj.stripe_id, expand=[ + 'schedule.phases.items.price']) + + # Possibilities: + # * No schedule, just a monthly plan + # * 3 phases: This script has already been run + # * 2 phases: Just asked for downgrade, so at current price, then new price, then no schedule + # * 2 phases: Already on 'new price' schedule, awaiting change - nothing to do + # * 1 phase: Been downgraded, so new price, then no schedule + # * 1 phase: On 'new price' schedule, change already happened - nothing to do + if subscription.schedule: + schedule = subscription.schedule + if len(schedule.phases) > 2: + self.stdout.write(f"{subscription.id} has {len(schedule.phases)} phases, assume processed already") + continue + elif len(schedule.phases) == 2: + if schedule.phases[1]['items'][0].price != old_price: + continue + phases = [ + schedule.phases[0], + schedule.phases[1], + { + 'items': [{'price': new_price}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [settings.STRIPE_TAX_RATE], + }, + ] + # Maintain current discount, if any + if schedule.phases[1].discounts and schedule.phases[1].discounts[0].coupon: + phases[2]['discounts'] = [{'coupon': schedule.phases[1].discounts[0].coupon}] + self.stdout.write(f"{subscription.id} has two phases, adding third phase to new price") + if options['commit']: + stripe.SubscriptionSchedule.modify(schedule.id, phases=phases) + else: # Must be 1 + if schedule.phases[0]['items'][0].price != old_price: + continue + self.stdout.write(f"{subscription.id} has one phase, releasing and adding schedule to new price") + if options['commit']: + stripe.SubscriptionSchedule.release(schedule) + self.new_schedule(subscription, new_price) + else: + if subscription['items'].data[0].price.id != old_price: + continue + self.stdout.write(f"{subscription.id} has no phase, adding schedule to new price") + if options['commit']: + self.new_schedule(subscription, new_price) diff --git a/subscriptions/tests.py b/subscriptions/tests.py index 818171f..bf58a54 100644 --- a/subscriptions/tests.py +++ b/subscriptions/tests.py @@ -683,6 +683,163 @@ def test_invoice_succeeded_sub_present(self): self.MockStripe.PaymentIntent.modify.assert_called_once_with('PI', description='MapIt') +class PriceChangeTest(PatchedStripeMixin, UserTestCase): + def setUp(self): + super().setUp() + Subscription.objects.create(user=self.user, stripe_id='ID') + self.MockStripe.Price.list.return_value = convert_to_stripe_object({ + 'data': [ + {'id': 'price_789', + 'metadata': {'calls': '0'}, + 'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}}, + {'id': 'price_789b', + 'metadata': {'calls': '0'}, + 'product': {'id': 'prod_GHI', 'name': 'MapIt, unlimited calls'}}, + {'id': 'price_123', + 'metadata': {'calls': '10000'}, + 'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}}, + {'id': 'price_123b', + 'metadata': {'calls': '10000'}, + 'product': {'id': 'prod_ABC', 'name': 'MapIt, 10,000 calls'}}, + {'id': 'price_456', + 'metadata': {'calls': '100000'}, + 'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}}, + {'id': 'price_456b', + 'metadata': {'calls': '100000'}, + 'product': {'id': 'prod_DEF', 'name': 'MapIt, 100,000 calls'}}, + ] + }, None, None) + + def test_change_price_charitable_sub(self): + with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe): + call_command( + 'schedule_price_change', '--old', 'price_789', '--new', 'price_789b', + '--commit', stdout=StringIO(), stderr=StringIO()) + + self.MockStripe.SubscriptionSchedule.create.assert_called_once_with( + from_subscription='SUBSCRIPTION-ID') + self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with( + 'SCHEDULE-ID', phases=[{ + 'items': [{'price': 'price_789'}], + 'start_date': ANY, + 'iterations': 2, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + 'discounts': [{'coupon': 'charitable50'}] + }, { + 'items': [{'price': 'price_789b'}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + 'discounts': [{'coupon': 'charitable50'}] + }]) + + def test_change_price_non_charitable_sub(self): + sub = self.MockStripe.SubscriptionSchedule.create.return_value + sub.phases[0].discounts = None + with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe): + call_command( + 'schedule_price_change', '--old', 'price_789', '--new', 'price_789b', + '--commit', stdout=StringIO(), stderr=StringIO()) + + self.MockStripe.SubscriptionSchedule.create.assert_called_once_with( + from_subscription='SUBSCRIPTION-ID') + self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with( + 'SCHEDULE-ID', phases=[{ + 'items': [{'price': 'price_789'}], + 'start_date': ANY, + 'iterations': 2, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + }, { + 'items': [{'price': 'price_789b'}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + }]) + + def test_change_price_just_downgraded(self): + sub = self.MockStripe.Subscription.retrieve.return_value + sub.schedule = convert_to_stripe_object({ + 'id': 'SCHEDULE-ID', + 'phases': [{ + 'start_date': time.time(), + 'end_date': time.time(), + 'discounts': [{'coupon': 'charitable50'}], + 'items': [{ + 'price': 'price_789', + }] + }, { + 'start_date': time.time(), + 'end_date': time.time(), + 'discounts': [{'coupon': 'charitable50'}], + 'items': [{ + 'price': 'price_456', + }] + }], + }, None, None) + with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe): + call_command( + 'schedule_price_change', '--old', 'price_456', '--new', 'price_456b', + '--commit', stdout=StringIO(), stderr=StringIO()) + + self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with( + 'SCHEDULE-ID', phases=[{ + 'items': [{'price': 'price_789'}], + 'start_date': ANY, + 'end_date': ANY, + 'discounts': [{'coupon': 'charitable50'}] + }, { + 'items': [{'price': 'price_456'}], + 'start_date': ANY, + 'end_date': ANY, + 'discounts': [{'coupon': 'charitable50'}] + }, { + 'items': [{'price': 'price_456b'}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + 'discounts': [{'coupon': 'charitable50'}] + }]) + + def test_change_price_downgraded_last_month(self): + sub = self.MockStripe.Subscription.retrieve.return_value + sub.schedule = convert_to_stripe_object({ + 'id': 'SCHEDULE-ID', + 'phases': [{ + 'start_date': time.time(), + 'end_date': time.time(), + 'discounts': [{'coupon': 'charitable50'}], + 'items': [{ + 'price': 'price_456', + }] + }], + }, None, None) + sub = self.MockStripe.SubscriptionSchedule.create.return_value + sub.phases[0].discounts = None + sub.phases[0]['items'][0].price = 'price_456' + with patch('subscriptions.management.commands.schedule_price_change.stripe', self.MockStripe): + call_command( + 'schedule_price_change', '--old', 'price_456', '--new', 'price_456b', + '--commit', stdout=StringIO(), stderr=StringIO()) + + self.MockStripe.SubscriptionSchedule.create.assert_called_once_with( + from_subscription='SUBSCRIPTION-ID') + self.MockStripe.SubscriptionSchedule.modify.assert_called_once_with( + 'SCHEDULE-ID', phases=[{ + 'items': [{'price': 'price_456'}], + 'iterations': 2, + 'start_date': ANY, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + }, { + 'items': [{'price': 'price_456b'}], + 'iterations': 1, + 'proration_behavior': 'none', + 'default_tax_rates': [ANY], + }]) + + @override_settings(REDIS_API_NAME='test_api') class ManagementTest(PatchedRedisTestCase): @override_settings(API_THROTTLE_UNLIMITED=['127.0.0.4'])